Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for site and data connection management.
|
||||
/// </summary>
|
||||
public interface ISiteRepository
|
||||
{
|
||||
// Sites
|
||||
Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default);
|
||||
Task AddSiteAsync(Site site, CancellationToken cancellationToken = default);
|
||||
Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default);
|
||||
Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// Data Connections
|
||||
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
||||
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
||||
Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// Site-Connection Assignments
|
||||
Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(int siteId, int dataConnectionId, CancellationToken cancellationToken = default);
|
||||
Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default);
|
||||
Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// Instances (for deletion constraint checks)
|
||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||
@@ -70,5 +71,13 @@ public interface ITemplateEngineRepository
|
||||
Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default);
|
||||
Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// SharedScript
|
||||
Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default);
|
||||
Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
|
||||
Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default);
|
||||
Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
43
src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs
Normal file
43
src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the difference between two FlattenedConfigurations (typically deployed vs current).
|
||||
/// Used for incremental deployment decisions and change review.
|
||||
/// </summary>
|
||||
public sealed record ConfigurationDiff
|
||||
{
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public string? OldRevisionHash { get; init; }
|
||||
public string? NewRevisionHash { get; init; }
|
||||
public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0;
|
||||
|
||||
public IReadOnlyList<DiffEntry<ResolvedAttribute>> AttributeChanges { get; init; } = [];
|
||||
public IReadOnlyList<DiffEntry<ResolvedAlarm>> AlarmChanges { get; init; } = [];
|
||||
public IReadOnlyList<DiffEntry<ResolvedScript>> ScriptChanges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single diff entry showing what changed for a named entity.
|
||||
/// </summary>
|
||||
public sealed record DiffEntry<T>
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public DiffChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The previous value (null for Added entries).
|
||||
/// </summary>
|
||||
public T? OldValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new value (null for Removed entries).
|
||||
/// </summary>
|
||||
public T? NewValue { get; init; }
|
||||
}
|
||||
|
||||
public enum DiffChangeType
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Changed
|
||||
}
|
||||
61
src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs
Normal file
61
src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// The complete deployment package for an instance, transmitted to site clusters and stored in SQLite.
|
||||
/// Contains the flattened configuration, revision hash, and deployment metadata.
|
||||
///
|
||||
/// JSON serialization format:
|
||||
/// {
|
||||
/// "instanceUniqueName": "PumpStation1",
|
||||
/// "deploymentId": "dep-abc123",
|
||||
/// "revisionHash": "sha256:...",
|
||||
/// "deployedBy": "admin@company.com",
|
||||
/// "deployedAtUtc": "2026-03-16T12:00:00Z",
|
||||
/// "configuration": { /* FlattenedConfiguration */ },
|
||||
/// "diff": { /* ConfigurationDiff, null for first deployment */ },
|
||||
/// "previousRevisionHash": null
|
||||
/// }
|
||||
/// </summary>
|
||||
public sealed record DeploymentPackage
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique name of the instance being deployed.
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Unique deployment ID for idempotency (deployment ID + revision hash).
|
||||
/// </summary>
|
||||
public string DeploymentId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the flattened configuration for staleness detection.
|
||||
/// </summary>
|
||||
public string RevisionHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The user who initiated the deployment.
|
||||
/// </summary>
|
||||
public string DeployedBy { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the deployment was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset DeployedAtUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully resolved configuration to deploy.
|
||||
/// </summary>
|
||||
public FlattenedConfiguration Configuration { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Diff against the previously deployed configuration. Null for first-time deployments.
|
||||
/// </summary>
|
||||
public ConfigurationDiff? Diff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The revision hash of the previously deployed configuration, if any.
|
||||
/// Used for optimistic concurrency on deployment status records.
|
||||
/// </summary>
|
||||
public string? PreviousRevisionHash { get; init; }
|
||||
}
|
||||
111
src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs
Normal file
111
src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// The fully resolved configuration for a single instance, produced by the flattening algorithm.
|
||||
/// All inheritance, composition, overrides, and connection bindings have been applied.
|
||||
/// This is the canonical representation sent to sites for deployment.
|
||||
/// </summary>
|
||||
public sealed record FlattenedConfiguration
|
||||
{
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public int TemplateId { get; init; }
|
||||
public int SiteId { get; init; }
|
||||
public int? AreaId { get; init; }
|
||||
public IReadOnlyList<ResolvedAttribute> Attributes { get; init; } = [];
|
||||
public IReadOnlyList<ResolvedAlarm> Alarms { get; init; } = [];
|
||||
public IReadOnlyList<ResolvedScript> Scripts { get; init; } = [];
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved attribute with its canonical name, value, data type, and optional data source binding.
|
||||
/// </summary>
|
||||
public sealed record ResolvedAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Path-qualified canonical name. For composed modules: "[ModuleInstanceName].[MemberName]".
|
||||
/// For direct attributes: just the attribute name.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
|
||||
public string? Value { get; init; }
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the relative tag path within the connection.
|
||||
/// </summary>
|
||||
public string? DataSourceReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the resolved connection ID from the instance binding.
|
||||
/// </summary>
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the resolved connection name.
|
||||
/// </summary>
|
||||
public string? BoundDataConnectionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the connection protocol (e.g. "OpcUa").
|
||||
/// </summary>
|
||||
public string? BoundDataConnectionProtocol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of this attribute value: "Template", "Inherited", "Composed", "Override".
|
||||
/// </summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved alarm with trigger definition containing resolved attribute references.
|
||||
/// </summary>
|
||||
public sealed record ResolvedAlarm
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public int PriorityLevel { get; init; }
|
||||
public bool IsLocked { get; init; }
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON trigger configuration with resolved attribute references (canonical names).
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical name of the on-trigger script, if any.
|
||||
/// </summary>
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
|
||||
public string Source { get; init; } = "Template";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved script with code, trigger config, parameters, and return definition.
|
||||
/// </summary>
|
||||
public sealed record ResolvedScript
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? TriggerType { get; init; }
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized parameter definitions.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized return type definition.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
|
||||
public TimeSpan? MinTimeBetweenRuns { get; init; }
|
||||
public string Source { get; init; } = "Template";
|
||||
}
|
||||
64
src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
Normal file
64
src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Result of pre-deployment or on-demand validation with categorized errors and warnings.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
public IReadOnlyList<ValidationEntry> Errors { get; init; } = [];
|
||||
public IReadOnlyList<ValidationEntry> Warnings { get; init; } = [];
|
||||
|
||||
public static ValidationResult Success() => new();
|
||||
|
||||
public static ValidationResult FromErrors(params ValidationEntry[] errors) =>
|
||||
new() { Errors = errors };
|
||||
|
||||
public static ValidationResult Merge(params ValidationResult[] results)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
foreach (var r in results)
|
||||
{
|
||||
errors.AddRange(r.Errors);
|
||||
warnings.AddRange(r.Warnings);
|
||||
}
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single validation error or warning with category and context.
|
||||
/// </summary>
|
||||
public sealed record ValidationEntry
|
||||
{
|
||||
public ValidationCategory Category { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical name of the entity that caused the validation issue, if applicable.
|
||||
/// </summary>
|
||||
public string? EntityName { get; init; }
|
||||
|
||||
public static ValidationEntry Error(ValidationCategory category, string message, string? entityName = null) =>
|
||||
new() { Category = category, Message = message, EntityName = entityName };
|
||||
|
||||
public static ValidationEntry Warning(ValidationCategory category, string message, string? entityName = null) =>
|
||||
new() { Category = category, Message = message, EntityName = entityName };
|
||||
}
|
||||
|
||||
public enum ValidationCategory
|
||||
{
|
||||
FlatteningFailure,
|
||||
NamingCollision,
|
||||
ScriptCompilation,
|
||||
AlarmTriggerReference,
|
||||
ScriptTriggerReference,
|
||||
ConnectionBinding,
|
||||
CallTargetNotFound,
|
||||
ParameterMismatch,
|
||||
ReturnTypeMismatch,
|
||||
TriggerOperandType,
|
||||
OnTriggerScriptNotFound,
|
||||
CrossCallViolation
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of IDeploymentManagerRepository.
|
||||
/// Provides storage/query of deployed configuration snapshots per instance,
|
||||
/// current deployment status, and optimistic concurrency on deployment status records.
|
||||
///
|
||||
/// WP-24: Stub level sufficient for diff/staleness support.
|
||||
/// </summary>
|
||||
public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _dbContext;
|
||||
|
||||
public DeploymentManagerRepository(ScadaLinkDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
}
|
||||
|
||||
// --- DeploymentRecord ---
|
||||
|
||||
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.Where(d => d.InstanceId == instanceId)
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent deployment record for an instance (current deployment status).
|
||||
/// Used for staleness detection by comparing revision hashes.
|
||||
/// </summary>
|
||||
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.Where(d => d.InstanceId == instanceId)
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a deployment record. Uses optimistic concurrency on deployment status records —
|
||||
/// EF Core's change tracking will detect concurrent modifications via the row version/concurrency token
|
||||
/// configured in the entity configuration.
|
||||
/// </summary>
|
||||
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.DeploymentRecords.Update(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteDeploymentRecordAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
||||
if (record != null)
|
||||
{
|
||||
_dbContext.DeploymentRecords.Remove(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new DeploymentRecord("stub", "stub") { Id = id };
|
||||
_dbContext.DeploymentRecords.Attach(stub);
|
||||
_dbContext.DeploymentRecords.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- SystemArtifactDeploymentRecord ---
|
||||
|
||||
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SystemArtifactDeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.SystemArtifactDeploymentRecords.Update(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
||||
if (record != null)
|
||||
{
|
||||
_dbContext.SystemArtifactDeploymentRecords.Remove(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
|
||||
_dbContext.SystemArtifactDeploymentRecords.Attach(stub);
|
||||
_dbContext.SystemArtifactDeploymentRecords.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of ISiteRepository for site and data connection management.
|
||||
/// </summary>
|
||||
public class SiteRepository : ISiteRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _dbContext;
|
||||
|
||||
public SiteRepository(ScadaLinkDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
}
|
||||
|
||||
// --- Sites ---
|
||||
|
||||
public async Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites
|
||||
.FirstOrDefaultAsync(s => s.SiteIdentifier == siteIdentifier, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddSiteAsync(Site site, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.Sites.AddAsync(site, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Sites.Update(site);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _dbContext.Sites.Local.FirstOrDefault(s => s.Id == id);
|
||||
if (entity != null)
|
||||
{
|
||||
_dbContext.Sites.Remove(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new Site("stub", "stub") { Id = id };
|
||||
_dbContext.Sites.Attach(stub);
|
||||
_dbContext.Sites.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Data Connections ---
|
||||
|
||||
public async Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connectionIds = await _dbContext.SiteDataConnectionAssignments
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Select(a => a.DataConnectionId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return await _dbContext.DataConnections
|
||||
.Where(c => connectionIds.Contains(c.Id))
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.DataConnections.AddAsync(connection, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.DataConnections.Update(connection);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _dbContext.DataConnections.Local.FirstOrDefault(c => c.Id == id);
|
||||
if (entity != null)
|
||||
{
|
||||
_dbContext.DataConnections.Remove(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new DataConnection("stub", "stub") { Id = id };
|
||||
_dbContext.DataConnections.Attach(stub);
|
||||
_dbContext.DataConnections.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Site-Connection Assignments ---
|
||||
|
||||
public async Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(
|
||||
int siteId, int dataConnectionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SiteDataConnectionAssignments
|
||||
.FirstOrDefaultAsync(a => a.SiteId == siteId && a.DataConnectionId == dataConnectionId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.SiteDataConnectionAssignments.AddAsync(assignment, cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _dbContext.SiteDataConnectionAssignments.Local.FirstOrDefault(a => a.Id == id);
|
||||
if (entity != null)
|
||||
{
|
||||
_dbContext.SiteDataConnectionAssignments.Remove(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new SiteDataConnectionAssignment { Id = id };
|
||||
_dbContext.SiteDataConnectionAssignments.Attach(stub);
|
||||
_dbContext.SiteDataConnectionAssignments.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Instances (for deletion constraint checks) ---
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public TemplateEngineRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
// Template
|
||||
|
||||
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await GetTemplateByIdAsync(id, cancellationToken);
|
||||
if (template == null) return null;
|
||||
|
||||
// Load all templates that have this template as parent
|
||||
var children = await _context.Templates
|
||||
.Where(t => t.ParentTemplateId == id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Templates.AddAsync(template, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Templates.Update(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (template != null)
|
||||
{
|
||||
_context.Templates.Remove(template);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateAttribute
|
||||
|
||||
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAttributes
|
||||
.Where(a => a.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateAttributes.Update(attribute);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (attribute != null)
|
||||
{
|
||||
_context.TemplateAttributes.Remove(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateAlarm
|
||||
|
||||
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAlarms
|
||||
.Where(a => a.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateAlarms.Update(alarm);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (alarm != null)
|
||||
{
|
||||
_context.TemplateAlarms.Remove(alarm);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateScript
|
||||
|
||||
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateScripts
|
||||
.Where(s => s.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateScripts.AddAsync(script, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateScripts.Update(script);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (script != null)
|
||||
{
|
||||
_context.TemplateScripts.Remove(script);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateComposition
|
||||
|
||||
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateCompositions
|
||||
.Where(c => c.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateCompositions.Update(composition);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (composition != null)
|
||||
{
|
||||
_context.TemplateCompositions.Remove(composition);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance
|
||||
|
||||
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Where(i => i.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Instances.AddAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Instances.Update(instance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (instance != null)
|
||||
{
|
||||
_context.Instances.Remove(instance);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceAttributeOverride
|
||||
|
||||
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAttributeOverrides
|
||||
.Where(o => o.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceAttributeOverrides.Update(attributeOverride);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (attributeOverride != null)
|
||||
{
|
||||
_context.InstanceAttributeOverrides.Remove(attributeOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceConnectionBinding
|
||||
|
||||
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceConnectionBindings
|
||||
.Where(b => b.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceConnectionBindings.Update(binding);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (binding != null)
|
||||
{
|
||||
_context.InstanceConnectionBindings.Remove(binding);
|
||||
}
|
||||
}
|
||||
|
||||
// Area
|
||||
|
||||
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.Include(a => a.Children)
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Include(a => a.Children)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Areas.AddAsync(area, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Areas.Update(area);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (area != null)
|
||||
{
|
||||
_context.Areas.Remove(area);
|
||||
}
|
||||
}
|
||||
|
||||
// SharedScript
|
||||
|
||||
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts
|
||||
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.SharedScripts.Update(sharedScript);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (sharedScript != null)
|
||||
{
|
||||
_context.SharedScripts.Remove(sharedScript);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
|
||||
159
src/ScadaLink.TemplateEngine/CollisionDetector.cs
Normal file
159
src/ScadaLink.TemplateEngine/CollisionDetector.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions across composed module members using canonical (path-qualified) names.
|
||||
/// Two members from different composed modules collide if they produce the same canonical name.
|
||||
/// Members from different module instance names cannot collide because the prefix differentiates them.
|
||||
/// </summary>
|
||||
public static class CollisionDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a resolved member with its canonical name and origin.
|
||||
/// </summary>
|
||||
public sealed record ResolvedMember(
|
||||
string CanonicalName,
|
||||
string MemberType, // "Attribute", "Alarm", "Script"
|
||||
string OriginDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions among all members (direct + composed) of a template.
|
||||
/// </summary>
|
||||
/// <param name="template">The template to check.</param>
|
||||
/// <param name="allTemplates">All templates in the system (for resolving composed templates).</param>
|
||||
/// <returns>List of collision descriptions. Empty if no collisions.</returns>
|
||||
public static IReadOnlyList<string> DetectCollisions(
|
||||
Template template,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
var allMembers = new List<ResolvedMember>();
|
||||
|
||||
// Collect direct (top-level) members
|
||||
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers);
|
||||
|
||||
// Collect members from composed modules recursively
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
prefix: composition.InstanceName,
|
||||
lookup,
|
||||
allMembers,
|
||||
visited: new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect inherited members (walk parent chain)
|
||||
CollectInheritedMembers(template, lookup, allMembers, new HashSet<int> { template.Id });
|
||||
|
||||
// Detect duplicates by canonical name
|
||||
var collisions = new List<string>();
|
||||
var grouped = allMembers.GroupBy(m => m.CanonicalName, StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var members = group.ToList();
|
||||
if (members.Count > 1)
|
||||
{
|
||||
// Only report collision if members come from different origins
|
||||
var distinctOrigins = members.Select(m => m.OriginDescription).Distinct().ToList();
|
||||
if (distinctOrigins.Count > 1)
|
||||
{
|
||||
var origins = string.Join(", ", members.Select(m => $"{m.MemberType} from {m.OriginDescription}"));
|
||||
collisions.Add($"Naming collision on '{group.Key}': {origins}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collisions;
|
||||
}
|
||||
|
||||
private static void CollectDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
string originPrefix,
|
||||
List<ResolvedMember> members)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Script", originPrefix));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectComposedMembers(
|
||||
Template template,
|
||||
string prefix,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
// Add direct members of this composed template with the prefix
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInheritedMembers(
|
||||
Template template,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!template.ParentTemplateId.HasValue)
|
||||
return;
|
||||
|
||||
if (!lookup.TryGetValue(template.ParentTemplateId.Value, out var parent))
|
||||
return;
|
||||
|
||||
if (!visited.Add(parent.Id))
|
||||
return;
|
||||
|
||||
// Inherited direct members (no prefix)
|
||||
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members);
|
||||
|
||||
// Inherited composed modules
|
||||
foreach (var composition in parent.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
composition.InstanceName,
|
||||
lookup,
|
||||
members,
|
||||
new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Continue up the inheritance chain
|
||||
CollectInheritedMembers(parent, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
162
src/ScadaLink.TemplateEngine/CycleDetector.cs
Normal file
162
src/ScadaLink.TemplateEngine/CycleDetector.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Detects cycles in template inheritance and composition graphs.
|
||||
/// Covers: self-inheritance, circular inheritance chains, self-composition,
|
||||
/// circular composition chains, and cross-graph (inheritance + composition) cycles.
|
||||
/// </summary>
|
||||
public static class CycleDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether setting <paramref name="parentId"/> as the parent of template
|
||||
/// <paramref name="templateId"/> would introduce an inheritance cycle.
|
||||
/// </summary>
|
||||
/// <returns>A description of the cycle if one would be created, or null if safe.</returns>
|
||||
public static string? DetectInheritanceCycle(
|
||||
int templateId,
|
||||
int parentId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
if (templateId == parentId)
|
||||
{
|
||||
var selfName = allTemplates.FirstOrDefault(t => t.Id == templateId)?.Name ?? templateId.ToString();
|
||||
return $"Template '{selfName}' cannot inherit from itself.";
|
||||
}
|
||||
|
||||
// Walk the inheritance chain from the proposed parent upward.
|
||||
// If we arrive back at templateId, there is a cycle.
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
var visited = new HashSet<int> { templateId };
|
||||
var chain = new List<string>();
|
||||
|
||||
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
|
||||
chain.Add(templateName);
|
||||
|
||||
var currentId = parentId;
|
||||
while (currentId != 0)
|
||||
{
|
||||
if (!lookup.TryGetValue(currentId, out var current))
|
||||
break;
|
||||
|
||||
chain.Add(current.Name);
|
||||
|
||||
if (visited.Contains(currentId))
|
||||
{
|
||||
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
|
||||
}
|
||||
|
||||
visited.Add(currentId);
|
||||
currentId = current.ParentTemplateId ?? 0;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether adding a composition of <paramref name="composedTemplateId"/> into
|
||||
/// <paramref name="templateId"/> would introduce a composition cycle.
|
||||
/// </summary>
|
||||
/// <returns>A description of the cycle if one would be created, or null if safe.</returns>
|
||||
public static string? DetectCompositionCycle(
|
||||
int templateId,
|
||||
int composedTemplateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
if (templateId == composedTemplateId)
|
||||
{
|
||||
var selfName = allTemplates.FirstOrDefault(t => t.Id == templateId)?.Name ?? templateId.ToString();
|
||||
return $"Template '{selfName}' cannot compose itself.";
|
||||
}
|
||||
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
|
||||
// BFS/DFS from composedTemplateId through all its compositions.
|
||||
// If we reach templateId, that's a cycle.
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(composedTemplateId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var currentId = queue.Dequeue();
|
||||
if (currentId == templateId)
|
||||
{
|
||||
var tmplName = lookup.TryGetValue(templateId, out var t1) ? t1.Name : templateId.ToString();
|
||||
var composedName = lookup.TryGetValue(composedTemplateId, out var t2) ? t2.Name : composedTemplateId.ToString();
|
||||
return $"Composition cycle detected: '{tmplName}' -> '{composedName}' -> ... -> '{tmplName}'.";
|
||||
}
|
||||
|
||||
if (!visited.Add(currentId))
|
||||
continue;
|
||||
|
||||
if (!lookup.TryGetValue(currentId, out var current))
|
||||
continue;
|
||||
|
||||
foreach (var comp in current.Compositions)
|
||||
{
|
||||
queue.Enqueue(comp.ComposedTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects cross-graph cycles that span both inheritance and composition edges.
|
||||
/// A cross-graph cycle exists when following any combination of inheritance (parent)
|
||||
/// and composition edges from a template leads back to itself.
|
||||
/// </summary>
|
||||
/// <returns>A description of the cycle if found, or null if safe.</returns>
|
||||
public static string? DetectCrossGraphCycle(
|
||||
int templateId,
|
||||
int? proposedParentId,
|
||||
int? proposedComposedTemplateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
|
||||
// Build adjacency: for each template, collect all reachable templates
|
||||
// via inheritance (parent) and composition edges.
|
||||
// We temporarily add the proposed edge and check for reachability back to templateId.
|
||||
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
|
||||
// Seed with proposed targets
|
||||
if (proposedParentId.HasValue && proposedParentId.Value != 0)
|
||||
queue.Enqueue(proposedParentId.Value);
|
||||
|
||||
if (proposedComposedTemplateId.HasValue && proposedComposedTemplateId.Value != 0)
|
||||
queue.Enqueue(proposedComposedTemplateId.Value);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var currentId = queue.Dequeue();
|
||||
if (currentId == templateId)
|
||||
{
|
||||
var tmplName = lookup.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
|
||||
return $"Cross-graph cycle detected involving template '{tmplName}'.";
|
||||
}
|
||||
|
||||
if (!visited.Add(currentId))
|
||||
continue;
|
||||
|
||||
if (!lookup.TryGetValue(currentId, out var current))
|
||||
continue;
|
||||
|
||||
// Follow inheritance edge
|
||||
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0)
|
||||
queue.Enqueue(current.ParentTemplateId.Value);
|
||||
|
||||
// Follow composition edges
|
||||
foreach (var comp in current.Compositions)
|
||||
{
|
||||
queue.Enqueue(comp.ComposedTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
135
src/ScadaLink.TemplateEngine/Flattening/DiffService.cs
Normal file
135
src/ScadaLink.TemplateEngine/Flattening/DiffService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two FlattenedConfigurations (deployed vs current) and produces a ConfigurationDiff
|
||||
/// showing Added, Removed, and Changed entries for attributes, alarms, and scripts.
|
||||
/// </summary>
|
||||
public class DiffService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the diff between an old (deployed) and new (current) flattened configuration.
|
||||
/// </summary>
|
||||
/// <param name="oldConfig">The previously deployed configuration. Null for first-time deployment.</param>
|
||||
/// <param name="newConfig">The current flattened configuration.</param>
|
||||
/// <param name="oldRevisionHash">The revision hash of the old config, if any.</param>
|
||||
/// <param name="newRevisionHash">The revision hash of the new config.</param>
|
||||
/// <returns>A ConfigurationDiff with all changes.</returns>
|
||||
public ConfigurationDiff ComputeDiff(
|
||||
FlattenedConfiguration? oldConfig,
|
||||
FlattenedConfiguration newConfig,
|
||||
string? oldRevisionHash = null,
|
||||
string? newRevisionHash = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newConfig);
|
||||
|
||||
var attributeChanges = ComputeEntityDiff(
|
||||
oldConfig?.Attributes ?? [],
|
||||
newConfig.Attributes,
|
||||
a => a.CanonicalName,
|
||||
AttributesEqual);
|
||||
|
||||
var alarmChanges = ComputeEntityDiff(
|
||||
oldConfig?.Alarms ?? [],
|
||||
newConfig.Alarms,
|
||||
a => a.CanonicalName,
|
||||
AlarmsEqual);
|
||||
|
||||
var scriptChanges = ComputeEntityDiff(
|
||||
oldConfig?.Scripts ?? [],
|
||||
newConfig.Scripts,
|
||||
s => s.CanonicalName,
|
||||
ScriptsEqual);
|
||||
|
||||
return new ConfigurationDiff
|
||||
{
|
||||
InstanceUniqueName = newConfig.InstanceUniqueName,
|
||||
OldRevisionHash = oldRevisionHash,
|
||||
NewRevisionHash = newRevisionHash,
|
||||
AttributeChanges = attributeChanges,
|
||||
AlarmChanges = alarmChanges,
|
||||
ScriptChanges = scriptChanges
|
||||
};
|
||||
}
|
||||
|
||||
private static List<DiffEntry<T>> ComputeEntityDiff<T>(
|
||||
IReadOnlyList<T> oldItems,
|
||||
IReadOnlyList<T> newItems,
|
||||
Func<T, string> getCanonicalName,
|
||||
Func<T, T, bool> areEqual)
|
||||
{
|
||||
var result = new List<DiffEntry<T>>();
|
||||
|
||||
var oldMap = oldItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);
|
||||
var newMap = newItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);
|
||||
|
||||
// Find removed and changed
|
||||
foreach (var (name, oldItem) in oldMap)
|
||||
{
|
||||
if (!newMap.TryGetValue(name, out var newItem))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Removed,
|
||||
OldValue = oldItem,
|
||||
NewValue = default
|
||||
});
|
||||
}
|
||||
else if (!areEqual(oldItem, newItem))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Changed,
|
||||
OldValue = oldItem,
|
||||
NewValue = newItem
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find added
|
||||
foreach (var (name, newItem) in newMap)
|
||||
{
|
||||
if (!oldMap.ContainsKey(name))
|
||||
{
|
||||
result.Add(new DiffEntry<T>
|
||||
{
|
||||
CanonicalName = name,
|
||||
ChangeType = DiffChangeType.Added,
|
||||
OldValue = default,
|
||||
NewValue = newItem
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(d => d.CanonicalName, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(ResolvedAttribute a, ResolvedAttribute b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Value == b.Value &&
|
||||
a.DataType == b.DataType &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.DataSourceReference == b.DataSourceReference &&
|
||||
a.BoundDataConnectionId == b.BoundDataConnectionId;
|
||||
|
||||
private static bool AlarmsEqual(ResolvedAlarm a, ResolvedAlarm b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.PriorityLevel == b.PriorityLevel &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.TriggerType == b.TriggerType &&
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.OnTriggerScriptCanonicalName == b.OnTriggerScriptCanonicalName;
|
||||
|
||||
private static bool ScriptsEqual(ResolvedScript a, ResolvedScript b) =>
|
||||
a.CanonicalName == b.CanonicalName &&
|
||||
a.Code == b.Code &&
|
||||
a.IsLocked == b.IsLocked &&
|
||||
a.TriggerType == b.TriggerType &&
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.ParameterDefinitions == b.ParameterDefinitions &&
|
||||
a.ReturnDefinition == b.ReturnDefinition &&
|
||||
a.MinTimeBetweenRuns == b.MinTimeBetweenRuns;
|
||||
}
|
||||
394
src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs
Normal file
394
src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the template flattening algorithm.
|
||||
/// Takes a template inheritance/composition graph plus instance overrides and connection bindings,
|
||||
/// and produces a fully resolved FlattenedConfiguration.
|
||||
///
|
||||
/// Resolution order (most specific wins):
|
||||
/// 1. Instance overrides (highest priority)
|
||||
/// 2. Child template (most derived first in inheritance chain)
|
||||
/// 3. Parent templates (walking up inheritance chain)
|
||||
/// 4. Composed modules (recursively flattened with path-qualified canonical names)
|
||||
///
|
||||
/// Locked fields cannot be overridden by instance overrides.
|
||||
/// </summary>
|
||||
public class FlatteningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces a fully flattened configuration for an instance.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance to flatten.</param>
|
||||
/// <param name="templateChain">
|
||||
/// The inheritance chain from most-derived to root (index 0 = the instance's template,
|
||||
/// last = the ultimate base template). Each template includes its own attributes, alarms, scripts.
|
||||
/// </param>
|
||||
/// <param name="compositionMap">
|
||||
/// Map of template ID → list of compositions (composed module definitions).
|
||||
/// For each composition, the key is the parent template ID and the value includes the
|
||||
/// composed template's resolved chain.
|
||||
/// </param>
|
||||
/// <param name="composedTemplateChains">
|
||||
/// Map of composed template ID → its inheritance chain (same format as templateChain).
|
||||
/// </param>
|
||||
/// <param name="dataConnections">
|
||||
/// Available data connections for resolving connection bindings.
|
||||
/// </param>
|
||||
/// <returns>A Result containing the FlattenedConfiguration or an error message.</returns>
|
||||
public Result<FlattenedConfiguration> Flatten(
|
||||
Instance instance,
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
if (templateChain.Count == 0)
|
||||
return Result<FlattenedConfiguration>.Failure("Template chain is empty.");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
||||
var attributes = ResolveInheritedAttributes(templateChain);
|
||||
|
||||
// Step 2: Resolve composed module attributes with path-qualified names
|
||||
ResolveComposedAttributes(templateChain, compositionMap, composedTemplateChains, attributes);
|
||||
|
||||
// Step 3: Apply instance overrides (respecting locks)
|
||||
ApplyInstanceOverrides(instance.AttributeOverrides, attributes);
|
||||
|
||||
// Step 4: Apply connection bindings
|
||||
ApplyConnectionBindings(instance.ConnectionBindings, attributes, dataConnections);
|
||||
|
||||
// Step 5: Resolve alarms from inheritance chain
|
||||
var alarms = ResolveInheritedAlarms(templateChain);
|
||||
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms);
|
||||
|
||||
// Step 6: Resolve scripts from inheritance chain
|
||||
var scripts = ResolveInheritedScripts(templateChain);
|
||||
ResolveComposedScripts(templateChain, compositionMap, composedTemplateChains, scripts);
|
||||
|
||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||
ResolveAlarmScriptReferences(alarms, scripts);
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instance.UniqueName,
|
||||
TemplateId = instance.TemplateId,
|
||||
SiteId = instance.SiteId,
|
||||
AreaId = instance.AreaId,
|
||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Result<FlattenedConfiguration>.Success(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<FlattenedConfiguration>.Failure($"Flattening failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedAttribute> ResolveInheritedAttributes(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
|
||||
// Walk from base (last) to most-derived (first) so derived values win
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
// If a parent defined this attribute as locked, derived cannot change the value
|
||||
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[attr.Name] = new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = attr.Name,
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType.ToString(),
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedAttributes(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
// Process compositions from each template in the chain
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedAttrs = ResolveInheritedAttributes(composedChain);
|
||||
|
||||
foreach (var (name, attr) in composedAttrs)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
// Don't overwrite if already defined (most-derived wins)
|
||||
if (!attributes.ContainsKey(canonicalName))
|
||||
{
|
||||
attributes[canonicalName] = attr with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(nested.ComposedTemplateId, out var nestedChain))
|
||||
continue;
|
||||
|
||||
var nestedPrefix = $"{prefix}.{nested.InstanceName}";
|
||||
var nestedAttrs = ResolveInheritedAttributes(nestedChain);
|
||||
|
||||
foreach (var (name, attr) in nestedAttrs)
|
||||
{
|
||||
var canonicalName = $"{nestedPrefix}.{name}";
|
||||
if (!attributes.ContainsKey(canonicalName))
|
||||
{
|
||||
attributes[canonicalName] = attr with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInstanceOverrides(
|
||||
ICollection<InstanceAttributeOverride> overrides,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!attributes.TryGetValue(ovr.AttributeName, out var existing))
|
||||
continue; // Cannot add new attributes via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked attributes cannot be overridden
|
||||
|
||||
attributes[ovr.AttributeName] = existing with
|
||||
{
|
||||
Value = ovr.OverrideValue,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyConnectionBindings(
|
||||
ICollection<InstanceConnectionBinding> bindings,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (!attributes.TryGetValue(binding.AttributeName, out var existing))
|
||||
continue;
|
||||
|
||||
if (existing.DataSourceReference == null)
|
||||
continue; // Only data-sourced attributes can have connection bindings
|
||||
|
||||
if (!dataConnections.TryGetValue(binding.DataConnectionId, out var connection))
|
||||
continue;
|
||||
|
||||
attributes[binding.AttributeName] = existing with
|
||||
{
|
||||
BoundDataConnectionId = connection.Id,
|
||||
BoundDataConnectionName = connection.Name,
|
||||
BoundDataConnectionProtocol = connection.Protocol
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedAlarm> ResolveInheritedAlarms(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAlarm>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[alarm.Name] = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = alarm.Name,
|
||||
Description = alarm.Description,
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedAlarms = ResolveInheritedAlarms(composedChain);
|
||||
|
||||
foreach (var (name, alarm) in composedAlarms)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!alarms.ContainsKey(canonicalName))
|
||||
{
|
||||
alarms[canonicalName] = alarm with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedScript> ResolveInheritedScripts(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedScript>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[script.Name] = new ResolvedScript
|
||||
{
|
||||
CanonicalName = script.Name,
|
||||
Code = script.Code,
|
||||
IsLocked = script.IsLocked,
|
||||
TriggerType = script.TriggerType,
|
||||
TriggerConfiguration = script.TriggerConfiguration,
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedScripts(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedScripts = ResolveInheritedScripts(composedChain);
|
||||
|
||||
foreach (var (name, script) in composedScripts)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!scripts.ContainsKey(canonicalName))
|
||||
{
|
||||
scripts[canonicalName] = script with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves alarm on-trigger script references from script IDs to canonical names.
|
||||
/// This is done by finding the script in the template chain whose ID matches the alarm's OnTriggerScriptId,
|
||||
/// then mapping to the corresponding canonical name in the resolved scripts.
|
||||
/// </summary>
|
||||
private static void ResolveAlarmScriptReferences(
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, ResolvedScript> scripts)
|
||||
{
|
||||
// Build a lookup of script names (we only have canonical names at this point)
|
||||
// The alarm's OnTriggerScriptCanonicalName will be set by the caller or validation step
|
||||
// For now, this is a placeholder — the actual resolution depends on how alarm trigger configs
|
||||
// reference scripts (by name within the same scope).
|
||||
// The trigger configuration JSON may contain a "scriptName" field.
|
||||
}
|
||||
}
|
||||
141
src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs
Normal file
141
src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||
/// Same content always produces the same hash across runs by using
|
||||
/// canonical JSON serialization with sorted keys.
|
||||
/// </summary>
|
||||
public class RevisionHashService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
// Sort properties alphabetically for determinism
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
// Ensure consistent ordering
|
||||
Converters = { new SortedPropertiesConverterFactory() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the given flattened configuration.
|
||||
/// The hash is computed over a canonical JSON representation with sorted keys,
|
||||
/// excluding volatile fields like GeneratedAtUtc.
|
||||
/// </summary>
|
||||
public string ComputeHash(FlattenedConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Create a hashable representation that excludes volatile fields
|
||||
var hashInput = new HashableConfiguration
|
||||
{
|
||||
InstanceUniqueName = configuration.InstanceUniqueName,
|
||||
TemplateId = configuration.TemplateId,
|
||||
SiteId = configuration.SiteId,
|
||||
AreaId = configuration.AreaId,
|
||||
Attributes = configuration.Attributes
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAttribute
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType,
|
||||
IsLocked = a.IsLocked,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
BoundDataConnectionId = a.BoundDataConnectionId
|
||||
})
|
||||
.ToList(),
|
||||
Alarms = configuration.Alarms
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAlarm
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
PriorityLevel = a.PriorityLevel,
|
||||
IsLocked = a.IsLocked,
|
||||
TriggerType = a.TriggerType,
|
||||
TriggerConfiguration = a.TriggerConfiguration,
|
||||
OnTriggerScriptCanonicalName = a.OnTriggerScriptCanonicalName
|
||||
})
|
||||
.ToList(),
|
||||
Scripts = configuration.Scripts
|
||||
.OrderBy(s => s.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(s => new HashableScript
|
||||
{
|
||||
CanonicalName = s.CanonicalName,
|
||||
Code = s.Code,
|
||||
IsLocked = s.IsLocked,
|
||||
TriggerType = s.TriggerType,
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
// Internal types for deterministic serialization (sorted property names via camelCase + alphabetical)
|
||||
private sealed record HashableConfiguration
|
||||
{
|
||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||
public int? AreaId { get; init; }
|
||||
public List<HashableAttribute> Attributes { get; init; } = [];
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public List<HashableScript> Scripts { get; init; } = [];
|
||||
public int SiteId { get; init; }
|
||||
public int TemplateId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAttribute
|
||||
{
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string? DataSourceReference { get; init; }
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAlarm
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
public int PriorityLevel { get; init; }
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableScript
|
||||
{
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public bool IsLocked { get; init; }
|
||||
public long? MinTimeBetweenRunsTicks { get; init; }
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
public string? ReturnDefinition { get; init; }
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
public string? TriggerType { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A JSON converter factory that ensures properties are serialized in alphabetical order
|
||||
/// for deterministic output. Works with record types.
|
||||
/// </summary>
|
||||
internal class SortedPropertiesConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert) => false;
|
||||
|
||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
|
||||
}
|
||||
109
src/ScadaLink.TemplateEngine/LockEnforcer.cs
Normal file
109
src/ScadaLink.TemplateEngine/LockEnforcer.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces locking rules for template member overrides.
|
||||
///
|
||||
/// Locking rules:
|
||||
/// - Locked members cannot be overridden downstream (child templates or compositions).
|
||||
/// - Any level can lock an unlocked member (intermediate locking).
|
||||
/// - Once locked, a member stays locked — it cannot be unlocked downstream.
|
||||
///
|
||||
/// Override granularity:
|
||||
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
|
||||
/// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed.
|
||||
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, params/return overridable; Name fixed.
|
||||
/// - Lock flag applies to the entire member (attribute/alarm/script).
|
||||
/// </summary>
|
||||
public static class LockEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that an attribute override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
public static string? ValidateAttributeOverride(
|
||||
TemplateAttribute original,
|
||||
TemplateAttribute proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Attribute '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// DataType is fixed — cannot change
|
||||
if (proposed.DataType != original.DataType)
|
||||
{
|
||||
return $"Attribute '{original.Name}': DataType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// DataSourceReference is fixed — cannot change
|
||||
if (proposed.DataSourceReference != original.DataSourceReference)
|
||||
{
|
||||
return $"Attribute '{original.Name}': DataSourceReference cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an alarm override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
public static string? ValidateAlarmOverride(
|
||||
TemplateAlarm original,
|
||||
TemplateAlarm proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Alarm '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// Name is fixed
|
||||
if (proposed.Name != original.Name)
|
||||
{
|
||||
return $"Alarm '{original.Name}': Name cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// TriggerType is fixed
|
||||
if (proposed.TriggerType != original.TriggerType)
|
||||
{
|
||||
return $"Alarm '{original.Name}': TriggerType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a script override does not violate lock or granularity rules.
|
||||
/// </summary>
|
||||
public static string? ValidateScriptOverride(
|
||||
TemplateScript original,
|
||||
TemplateScript proposed)
|
||||
{
|
||||
if (original.IsLocked)
|
||||
{
|
||||
return $"Script '{original.Name}' is locked and cannot be overridden.";
|
||||
}
|
||||
|
||||
// Name is fixed
|
||||
if (proposed.Name != original.Name)
|
||||
{
|
||||
return $"Script '{original.Name}': Name cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a lock flag change is legal.
|
||||
/// Locking is allowed on unlocked members. Unlocking is never allowed.
|
||||
/// </summary>
|
||||
public static string? ValidateLockChange(bool originalIsLocked, bool proposedIsLocked, string memberName)
|
||||
{
|
||||
if (originalIsLocked && !proposedIsLocked)
|
||||
{
|
||||
return $"Member '{memberName}' is locked and cannot be unlocked.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.TemplateEngine.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
|
||||
@@ -6,7 +6,9 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddTemplateEngine(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddScoped<TemplateService>();
|
||||
services.AddScoped<SharedScriptService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
176
src/ScadaLink.TemplateEngine/Services/AreaService.cs
Normal file
176
src/ScadaLink.TemplateEngine/Services/AreaService.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical area management per site.
|
||||
/// - CRUD for areas with parent-child relationships
|
||||
/// - Deletion constrained if instances are assigned
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class AreaService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public AreaService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new area within a site.
|
||||
/// </summary>
|
||||
public async Task<Result<Area>> CreateAreaAsync(
|
||||
string name, int siteId, int? parentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
// Validate parent area if specified
|
||||
if (parentAreaId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetAreaByIdAsync(parentAreaId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} not found.");
|
||||
if (parent.SiteId != siteId)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.ParentAreaId == parentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level in site {siteId}.");
|
||||
|
||||
var area = new Area(name)
|
||||
{
|
||||
SiteId = siteId,
|
||||
ParentAreaId = parentAreaId
|
||||
};
|
||||
|
||||
await _repository.AddAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an area's name.
|
||||
/// </summary>
|
||||
public async Task<Result<Area>> UpdateAreaAsync(
|
||||
int areaId, string name, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == area.ParentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level.");
|
||||
|
||||
area.Name = name;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAreaAsync(
|
||||
int areaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<bool>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for instances assigned to this area
|
||||
var allInstances = await _repository.GetInstancesBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var allAreas = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Collect this area and all descendant area IDs
|
||||
var descendantIds = GetDescendantAreaIds(areaId, allAreas);
|
||||
descendantIds.Add(areaId);
|
||||
|
||||
var assignedInstances = allInstances
|
||||
.Where(i => i.AreaId.HasValue && descendantIds.Contains(i.AreaId.Value))
|
||||
.ToList();
|
||||
|
||||
if (assignedInstances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", assignedInstances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': {assignedInstances.Count} instance(s) are assigned to it or its sub-areas ({names}{(assignedInstances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check for child areas (must delete children first, or we delete recursively)
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == areaId).ToList();
|
||||
if (childAreas.Count > 0)
|
||||
{
|
||||
var childNames = string.Join(", ", childAreas.Select(a => a.Name));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': it has child areas ({childNames}). Delete child areas first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteAreaAsync(areaId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Area", areaId.ToString(), area.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all areas for a site.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single area by ID.
|
||||
/// </summary>
|
||||
public async Task<Area?> GetAreaByIdAsync(int areaId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
|
||||
private static HashSet<int> GetDescendantAreaIds(int parentId, IReadOnlyList<Area> allAreas)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(parentId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var child in allAreas.Where(a => a.ParentAreaId == current))
|
||||
{
|
||||
if (result.Add(child.Id))
|
||||
queue.Enqueue(child.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
285
src/ScadaLink.TemplateEngine/Services/InstanceService.cs
Normal file
285
src/ScadaLink.TemplateEngine/Services/InstanceService.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Instance CRUD operations.
|
||||
/// - Create instance from template at site
|
||||
/// - Assign to area
|
||||
/// - Override non-locked attribute values
|
||||
/// - Cannot add or remove attributes (only override existing ones)
|
||||
/// - Per-attribute connection binding (bulk assignment support)
|
||||
/// - Enabled/disabled state with optimistic concurrency
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance from a template at a site.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> CreateInstanceAsync(
|
||||
string uniqueName,
|
||||
int templateId,
|
||||
int siteId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uniqueName))
|
||||
return Result<Instance>.Failure("Instance unique name is required.");
|
||||
|
||||
// Verify template exists
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for duplicate unique name
|
||||
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
|
||||
|
||||
// Verify area exists if specified
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != siteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
var instance = new Instance(uniqueName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SiteId = siteId,
|
||||
AreaId = areaId,
|
||||
State = InstanceState.Disabled // New instances start disabled
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
|
||||
uniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns an instance to an area.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> AssignToAreaAsync(
|
||||
int instanceId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != instance.SiteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
|
||||
}
|
||||
|
||||
instance.AreaId = areaId;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
|
||||
/// Cannot add or remove attributes — only override values of existing template attributes.
|
||||
/// </summary>
|
||||
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
|
||||
int instanceId,
|
||||
string attributeName,
|
||||
string? overrideValue,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify attribute exists in the template and is not locked
|
||||
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
||||
if (templateAttr == null)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
|
||||
|
||||
if (templateAttr.IsLocked)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' is locked and cannot be overridden.");
|
||||
|
||||
// Find existing override or create new one
|
||||
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.OverrideValue = overrideValue;
|
||||
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
|
||||
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAttributeOverride(attributeName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
OverrideValue = overrideValue
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
|
||||
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
||||
int instanceId,
|
||||
IReadOnlyList<(string AttributeName, int DataConnectionId)> bindings,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
|
||||
|
||||
var results = new List<InstanceConnectionBinding>();
|
||||
|
||||
foreach (var (attrName, connId) in bindings)
|
||||
{
|
||||
if (existingMap.TryGetValue(attrName, out var existing))
|
||||
{
|
||||
existing.DataConnectionId = connId;
|
||||
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
||||
results.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var binding = new InstanceConnectionBinding(attrName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DataConnectionId = connId
|
||||
};
|
||||
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
||||
results.Add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
|
||||
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
|
||||
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Enabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Disabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an instance.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an instance by ID.
|
||||
/// </summary>
|
||||
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all instances for a site.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
}
|
||||
206
src/ScadaLink.TemplateEngine/Services/SiteService.cs
Normal file
206
src/ScadaLink.TemplateEngine/Services/SiteService.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Site and data connection management.
|
||||
/// - Site CRUD (name, identifier, description)
|
||||
/// - Data connection CRUD (name, protocol, config)
|
||||
/// - Assign connections to sites
|
||||
/// - Connection names not standardized across sites
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class SiteService
|
||||
{
|
||||
private readonly ISiteRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public SiteService(ISiteRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
// --- Site CRUD ---
|
||||
|
||||
public async Task<Result<Site>> CreateSiteAsync(
|
||||
string name, string siteIdentifier, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Site>.Failure("Site name is required.");
|
||||
if (string.IsNullOrWhiteSpace(siteIdentifier))
|
||||
return Result<Site>.Failure("Site identifier is required.");
|
||||
|
||||
var existing = await _repository.GetSiteByIdentifierAsync(siteIdentifier, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Site>.Failure($"Site with identifier '{siteIdentifier}' already exists.");
|
||||
|
||||
var site = new Site(name, siteIdentifier) { Description = description };
|
||||
await _repository.AddSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
public async Task<Result<Site>> UpdateSiteAsync(
|
||||
int siteId, string name, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<Site>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
site.Name = name;
|
||||
site.Description = description;
|
||||
await _repository.UpdateSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteSiteAsync(int siteId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<bool>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
// Check for instances assigned to this site
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete site '{site.Name}': {instances.Count} instance(s) are assigned to it ({names}{(instances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
await _repository.DeleteSiteAsync(siteId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Site", siteId.ToString(), site.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
public async Task<Site?> GetSiteByIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAllSitesAsync(cancellationToken);
|
||||
|
||||
// --- Data Connection CRUD ---
|
||||
|
||||
public async Task<Result<DataConnection>> CreateDataConnectionAsync(
|
||||
string name, string protocol, string? configuration, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<DataConnection>.Failure("Data connection name is required.");
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
return Result<DataConnection>.Failure("Protocol is required.");
|
||||
|
||||
var connection = new DataConnection(name, protocol) { Configuration = configuration };
|
||||
await _repository.AddDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
public async Task<Result<DataConnection>> UpdateDataConnectionAsync(
|
||||
int connectionId, string name, string protocol, string? configuration, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<DataConnection>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
connection.Name = name;
|
||||
connection.Protocol = protocol;
|
||||
connection.Configuration = configuration;
|
||||
await _repository.UpdateDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteDataConnectionAsync(
|
||||
int connectionId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<bool>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(connectionId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "DataConnection",
|
||||
connectionId.ToString(), connection.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// --- Site-Connection Assignment ---
|
||||
|
||||
public async Task<Result<SiteDataConnectionAssignment>> AssignConnectionToSiteAsync(
|
||||
int siteId, int dataConnectionId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(dataConnectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure($"Data connection with ID {dataConnectionId} not found.");
|
||||
|
||||
// Check if assignment already exists
|
||||
var existing = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<SiteDataConnectionAssignment>.Failure(
|
||||
$"Data connection '{connection.Name}' is already assigned to site '{site.Name}'.");
|
||||
|
||||
var assignment = new SiteDataConnectionAssignment
|
||||
{
|
||||
SiteId = siteId,
|
||||
DataConnectionId = dataConnectionId
|
||||
};
|
||||
|
||||
await _repository.AddSiteDataConnectionAssignmentAsync(assignment, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignConnection", "SiteDataConnectionAssignment",
|
||||
assignment.Id.ToString(), $"{site.Name}/{connection.Name}", assignment, cancellationToken);
|
||||
|
||||
return Result<SiteDataConnectionAssignment>.Success(assignment);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> RemoveConnectionFromSiteAsync(
|
||||
int siteId, int dataConnectionId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assignment = await _repository.GetSiteDataConnectionAssignmentAsync(siteId, dataConnectionId, cancellationToken);
|
||||
if (assignment == null)
|
||||
return Result<bool>.Failure("Assignment not found.");
|
||||
|
||||
await _repository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "RemoveConnection", "SiteDataConnectionAssignment",
|
||||
assignment.Id.ToString(), $"Site:{siteId}/Conn:{dataConnectionId}", null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces template deletion constraints (WP-25).
|
||||
/// Template deletion is blocked when:
|
||||
/// - Instances reference the template
|
||||
/// - Child templates reference it (as parent)
|
||||
/// - Other templates compose it
|
||||
/// Returns clear error messages listing the referencing entities.
|
||||
/// </summary>
|
||||
public class TemplateDeletionService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
|
||||
public TemplateDeletionService(ITemplateEngineRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a template can be safely deleted and returns any blocking reasons.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> CanDeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check 1: Instances reference this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 2: Child templates reference it as parent
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
if (childTemplates.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it
|
||||
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
||||
foreach (var t in allTemplates)
|
||||
{
|
||||
var compositions = await _repository.GetCompositionsByTemplateIdAsync(t.Id, cancellationToken);
|
||||
foreach (var comp in compositions)
|
||||
{
|
||||
if (comp.ComposedTemplateId == templateId)
|
||||
composingTemplates.Add((t.Name, comp.InstanceName));
|
||||
}
|
||||
}
|
||||
|
||||
if (composingTemplates.Count > 0)
|
||||
{
|
||||
var details = string.Join(", ",
|
||||
composingTemplates.Take(10).Select(c => $"'{c.TemplateName}' (as '{c.InstanceName}')"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {composingTemplates.Count} template(s) compose it ({details}{(composingTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
return Result<bool>.Failure(string.Join(" ", errors));
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template after checking all constraints.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var canDelete = await CanDeleteTemplateAsync(templateId, cancellationToken);
|
||||
if (canDelete.IsFailure)
|
||||
return canDelete;
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
163
src/ScadaLink.TemplateEngine/SharedScriptService.cs
Normal file
163
src/ScadaLink.TemplateEngine/SharedScriptService.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// WP-5: Shared Script CRUD.
|
||||
/// System-wide scripts not associated with templates.
|
||||
/// Same parameter/return definition structure as template scripts.
|
||||
/// Includes syntax/structural validation (basic C# compilation check).
|
||||
/// </summary>
|
||||
public class SharedScriptService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public SharedScriptService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
public async Task<Result<SharedScript>> CreateSharedScriptAsync(
|
||||
string name,
|
||||
string code,
|
||||
string? parameterDefinitions,
|
||||
string? returnDefinition,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<SharedScript>.Failure("Shared script name is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<SharedScript>.Failure("Shared script code is required.");
|
||||
|
||||
// Check unique name
|
||||
var existing = await _repository.GetSharedScriptByNameAsync(name, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<SharedScript>.Failure($"A shared script named '{name}' already exists.");
|
||||
|
||||
// Syntax/structural validation
|
||||
var syntaxError = ValidateSyntax(code);
|
||||
if (syntaxError != null)
|
||||
return Result<SharedScript>.Failure(syntaxError);
|
||||
|
||||
var script = new SharedScript(name, code)
|
||||
{
|
||||
ParameterDefinitions = parameterDefinitions,
|
||||
ReturnDefinition = returnDefinition
|
||||
};
|
||||
|
||||
await _repository.AddSharedScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<SharedScript>.Success(script);
|
||||
}
|
||||
|
||||
public async Task<Result<SharedScript>> UpdateSharedScriptAsync(
|
||||
int scriptId,
|
||||
string code,
|
||||
string? parameterDefinitions,
|
||||
string? returnDefinition,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (script == null)
|
||||
return Result<SharedScript>.Failure($"Shared script with ID {scriptId} not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<SharedScript>.Failure("Shared script code is required.");
|
||||
|
||||
// Syntax/structural validation
|
||||
var syntaxError = ValidateSyntax(code);
|
||||
if (syntaxError != null)
|
||||
return Result<SharedScript>.Failure(syntaxError);
|
||||
|
||||
script.Code = code;
|
||||
script.ParameterDefinitions = parameterDefinitions;
|
||||
script.ReturnDefinition = returnDefinition;
|
||||
|
||||
await _repository.UpdateSharedScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "SharedScript", scriptId.ToString(), script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<SharedScript>.Success(script);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteSharedScriptAsync(
|
||||
int scriptId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (script == null)
|
||||
return Result<bool>.Failure($"Shared script with ID {scriptId} not found.");
|
||||
|
||||
await _repository.DeleteSharedScriptAsync(scriptId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "SharedScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
public async Task<SharedScript?> GetSharedScriptByIdAsync(int scriptId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetSharedScriptByIdAsync(scriptId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetAllSharedScriptsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic structural validation of C# script code.
|
||||
/// Checks for balanced braces and basic syntax structure.
|
||||
/// Full Roslyn compilation would be added in a later phase when the scripting sandbox is available.
|
||||
/// </summary>
|
||||
internal static string? ValidateSyntax(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return "Script code cannot be empty.";
|
||||
|
||||
// Check for balanced braces
|
||||
int braceCount = 0;
|
||||
int bracketCount = 0;
|
||||
int parenCount = 0;
|
||||
|
||||
foreach (var ch in code)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '{': braceCount++; break;
|
||||
case '}': braceCount--; break;
|
||||
case '[': bracketCount++; break;
|
||||
case ']': bracketCount--; break;
|
||||
case '(': parenCount++; break;
|
||||
case ')': parenCount--; break;
|
||||
}
|
||||
|
||||
if (braceCount < 0)
|
||||
return "Syntax error: unmatched closing brace '}'.";
|
||||
if (bracketCount < 0)
|
||||
return "Syntax error: unmatched closing bracket ']'.";
|
||||
if (parenCount < 0)
|
||||
return "Syntax error: unmatched closing parenthesis ')'.";
|
||||
}
|
||||
|
||||
if (braceCount != 0)
|
||||
return "Syntax error: unmatched opening brace '{'.";
|
||||
if (bracketCount != 0)
|
||||
return "Syntax error: unmatched opening bracket '['.";
|
||||
if (parenCount != 0)
|
||||
return "Syntax error: unmatched opening parenthesis '('.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
155
src/ScadaLink.TemplateEngine/TemplateResolver.cs
Normal file
155
src/ScadaLink.TemplateEngine/TemplateResolver.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Walks inheritance and composition chains to resolve effective template members.
|
||||
/// Produces canonical (path-qualified) names for composed module members.
|
||||
/// </summary>
|
||||
public static class TemplateResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a resolved member from any point in the inheritance/composition hierarchy.
|
||||
/// </summary>
|
||||
public sealed record ResolvedTemplateMember
|
||||
{
|
||||
public string CanonicalName { get; init; }
|
||||
public string MemberType { get; init; } // "Attribute", "Alarm", "Script"
|
||||
public int SourceTemplateId { get; init; }
|
||||
public int MemberId { get; init; }
|
||||
public bool IsLocked { get; init; }
|
||||
public string? ModulePath { get; init; }
|
||||
|
||||
public ResolvedTemplateMember(string canonicalName, string memberType, int sourceTemplateId, int memberId, bool isLocked, string? modulePath = null)
|
||||
{
|
||||
CanonicalName = canonicalName;
|
||||
MemberType = memberType;
|
||||
SourceTemplateId = sourceTemplateId;
|
||||
MemberId = memberId;
|
||||
IsLocked = isLocked;
|
||||
ModulePath = modulePath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all effective members for a template, walking inheritance and composition chains.
|
||||
/// Child members override parent members of the same canonical name (unless locked).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ResolvedTemplateMember> ResolveAllMembers(
|
||||
int templateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
if (!lookup.TryGetValue(templateId, out var template))
|
||||
return Array.Empty<ResolvedTemplateMember>();
|
||||
|
||||
// Build inheritance chain from root to leaf (root first, child last)
|
||||
var chain = BuildInheritanceChain(templateId, lookup);
|
||||
|
||||
// Start with root members, apply overrides from each child
|
||||
var effectiveMembers = new Dictionary<string, ResolvedTemplateMember>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tmpl in chain)
|
||||
{
|
||||
// Direct members
|
||||
AddDirectMembers(tmpl, prefix: null, effectiveMembers);
|
||||
|
||||
// Composed module members
|
||||
foreach (var composition in tmpl.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
AddComposedMembers(composedTemplate, composition.InstanceName, lookup, effectiveMembers, new HashSet<int>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveMembers.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inheritance chain from root ancestor to the specified template.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Template> BuildInheritanceChain(
|
||||
int templateId,
|
||||
IReadOnlyDictionary<int, Template> lookup)
|
||||
{
|
||||
var chain = new List<Template>();
|
||||
var currentId = templateId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
|
||||
{
|
||||
if (!visited.Add(currentId))
|
||||
break; // Safety: cycle detected
|
||||
|
||||
chain.Add(current);
|
||||
currentId = current.ParentTemplateId ?? 0;
|
||||
}
|
||||
|
||||
chain.Reverse(); // Root first
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a member by canonical name in the resolved member set.
|
||||
/// Used to check override/lock constraints.
|
||||
/// </summary>
|
||||
public static ResolvedTemplateMember? FindMemberByCanonicalName(
|
||||
string canonicalName,
|
||||
int parentTemplateId,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var members = ResolveAllMembers(parentTemplateId, allTemplates);
|
||||
return members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
||||
}
|
||||
|
||||
private static void AddDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
Dictionary<string, ResolvedTemplateMember> effectiveMembers)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Attribute", template.Id, attr.Id, attr.IsLocked, prefix);
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Alarm", template.Id, alarm.Id, alarm.IsLocked, prefix);
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
effectiveMembers[canonicalName] = new ResolvedTemplateMember(
|
||||
canonicalName, "Script", template.Id, script.Id, script.IsLocked, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddComposedMembers(
|
||||
Template template,
|
||||
string prefix,
|
||||
Dictionary<int, Template> lookup,
|
||||
Dictionary<string, ResolvedTemplateMember> effectiveMembers,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
AddDirectMembers(template, prefix, effectiveMembers);
|
||||
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
AddComposedMembers(nested, nestedPrefix, lookup, effectiveMembers, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
751
src/ScadaLink.TemplateEngine/TemplateService.cs
Normal file
751
src/ScadaLink.TemplateEngine/TemplateService.cs
Normal file
@@ -0,0 +1,751 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Core service for Template Engine operations.
|
||||
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
|
||||
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
|
||||
/// </summary>
|
||||
public class TemplateService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-1: Template CRUD with Inheritance
|
||||
// ========================================================================
|
||||
|
||||
public async Task<Result<Template>> CreateTemplateAsync(
|
||||
string name,
|
||||
string? description,
|
||||
int? parentTemplateId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Template>.Failure("Template name is required.");
|
||||
|
||||
// Validate parent exists if specified
|
||||
if (parentTemplateId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
|
||||
}
|
||||
|
||||
var template = new Template(name)
|
||||
{
|
||||
Description = description,
|
||||
ParentTemplateId = parentTemplateId
|
||||
};
|
||||
|
||||
// Check acyclicity (inheritance) — for new templates this is mostly a parent-exists check,
|
||||
// but we validate anyway for consistency
|
||||
if (parentTemplateId.HasValue)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
// The new template doesn't exist yet, so we simulate by adding it to the list
|
||||
// with a temporary ID. Since it has no children yet, the only cycle would be
|
||||
// if parentTemplateId somehow pointed at itself (already handled above).
|
||||
}
|
||||
|
||||
await _repository.AddTemplateAsync(template, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<Template>.Success(template);
|
||||
}
|
||||
|
||||
public async Task<Result<Template>> UpdateTemplateAsync(
|
||||
int templateId,
|
||||
string name,
|
||||
string? description,
|
||||
int? parentTemplateId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Template>.Failure("Template name is required.");
|
||||
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<Template>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Validate parent change
|
||||
if (parentTemplateId.HasValue && parentTemplateId.Value != (template.ParentTemplateId ?? 0))
|
||||
{
|
||||
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
|
||||
|
||||
// Check inheritance acyclicity
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var cycleError = CycleDetector.DetectInheritanceCycle(templateId, parentTemplateId.Value, allTemplates);
|
||||
if (cycleError != null)
|
||||
return Result<Template>.Failure(cycleError);
|
||||
|
||||
// Check cross-graph cycle
|
||||
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, parentTemplateId, null, allTemplates);
|
||||
if (crossCycleError != null)
|
||||
return Result<Template>.Failure(crossCycleError);
|
||||
}
|
||||
|
||||
template.Name = name;
|
||||
template.Description = description;
|
||||
template.ParentTemplateId = parentTemplateId;
|
||||
|
||||
// Check for naming collisions after the change
|
||||
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
|
||||
if (collisionResult != null)
|
||||
return Result<Template>.Failure(collisionResult);
|
||||
|
||||
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<Template>.Success(template);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteTemplateAsync(
|
||||
int templateId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for instances referencing this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
||||
|
||||
// Check for child templates inheriting from this template
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var children = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
if (children.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is inherited by {children.Count} child template(s): " +
|
||||
string.Join(", ", children.Select(c => $"'{c.Name}'")));
|
||||
|
||||
// Check for templates composing this template
|
||||
var composedBy = allTemplates
|
||||
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId))
|
||||
.ToList();
|
||||
if (composedBy.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is composed by {composedBy.Count} template(s): " +
|
||||
string.Join(", ", composedBy.Select(c => $"'{c.Name}'")));
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-2: Attribute Definitions with Lock Flags
|
||||
// ========================================================================
|
||||
|
||||
public async Task<Result<TemplateAttribute>> AddAttributeAsync(
|
||||
int templateId,
|
||||
TemplateAttribute attribute,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<TemplateAttribute>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for duplicate name at this template level
|
||||
if (template.Attributes.Any(a => a.Name == attribute.Name))
|
||||
return Result<TemplateAttribute>.Failure(
|
||||
$"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");
|
||||
|
||||
attribute.TemplateId = templateId;
|
||||
|
||||
// If inheriting, validate not trying to add a member that would collide
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
|
||||
|
||||
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(attribute);
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
|
||||
int attributeId,
|
||||
TemplateAttribute proposed,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<TemplateAttribute>.Failure($"Attribute with ID {attributeId} not found.");
|
||||
|
||||
// Validate override rules if this is an override (parent has same-named attribute)
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateAttribute>.Failure(
|
||||
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
}
|
||||
|
||||
// Validate lock change rules
|
||||
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
||||
if (lockError != null)
|
||||
return Result<TemplateAttribute>.Failure(lockError);
|
||||
|
||||
// Validate fixed-field granularity
|
||||
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
|
||||
if (granularityError != null && existing.IsLocked)
|
||||
return Result<TemplateAttribute>.Failure(granularityError);
|
||||
|
||||
// Apply overridable fields
|
||||
existing.Value = proposed.Value;
|
||||
existing.Description = proposed.Description;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
// DataType and DataSourceReference are NOT updated (fixed fields)
|
||||
|
||||
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(existing);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteAttributeAsync(
|
||||
int attributeId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
||||
if (attribute == null)
|
||||
return Result<bool>.Failure($"Attribute with ID {attributeId} not found.");
|
||||
|
||||
// Cannot remove inherited parent members — only direct members
|
||||
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
attribute.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
|
||||
}
|
||||
|
||||
await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-3: Alarm Definitions
|
||||
// ========================================================================
|
||||
|
||||
public async Task<Result<TemplateAlarm>> AddAlarmAsync(
|
||||
int templateId,
|
||||
TemplateAlarm alarm,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
if (template.Alarms.Any(a => a.Name == alarm.Name))
|
||||
return Result<TemplateAlarm>.Failure(
|
||||
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
|
||||
|
||||
// Validate priority range
|
||||
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
|
||||
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
||||
|
||||
alarm.TemplateId = templateId;
|
||||
|
||||
// Check collisions
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
|
||||
|
||||
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(alarm);
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
|
||||
int alarmId,
|
||||
TemplateAlarm proposed,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");
|
||||
|
||||
// Validate priority range
|
||||
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
|
||||
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
||||
|
||||
// Validate lock change
|
||||
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
||||
if (lockError != null)
|
||||
return Result<TemplateAlarm>.Failure(lockError);
|
||||
|
||||
// Check parent lock
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateAlarm>.Failure(
|
||||
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
}
|
||||
|
||||
// Validate fixed fields
|
||||
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
|
||||
if (overrideError != null)
|
||||
return Result<TemplateAlarm>.Failure(overrideError);
|
||||
|
||||
// Apply overridable fields
|
||||
existing.PriorityLevel = proposed.PriorityLevel;
|
||||
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
||||
existing.Description = proposed.Description;
|
||||
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
// Name and TriggerType are NOT updated (fixed)
|
||||
|
||||
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(existing);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteAlarmAsync(
|
||||
int alarmId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
||||
if (alarm == null)
|
||||
return Result<bool>.Failure($"Alarm with ID {alarmId} not found.");
|
||||
|
||||
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
alarm.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
|
||||
}
|
||||
|
||||
await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-4: Script Definitions
|
||||
// ========================================================================
|
||||
|
||||
public async Task<Result<TemplateScript>> AddScriptAsync(
|
||||
int templateId,
|
||||
TemplateScript script,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
if (template.Scripts.Any(s => s.Name == script.Name))
|
||||
return Result<TemplateScript>.Failure(
|
||||
$"Script '{script.Name}' already exists on template '{template.Name}'.");
|
||||
|
||||
script.TemplateId = templateId;
|
||||
|
||||
// Check collisions
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var testTemplate = CloneTemplateWithNewScript(template, script);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
|
||||
|
||||
await _repository.AddTemplateScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(script);
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateScript>> UpdateScriptAsync(
|
||||
int scriptId,
|
||||
TemplateScript proposed,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<TemplateScript>.Failure($"Script with ID {scriptId} not found.");
|
||||
|
||||
// Validate lock change
|
||||
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
||||
if (lockError != null)
|
||||
return Result<TemplateScript>.Failure(lockError);
|
||||
|
||||
// Check parent lock
|
||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateScript>.Failure(
|
||||
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
}
|
||||
|
||||
// Validate fixed fields
|
||||
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
|
||||
if (overrideError != null)
|
||||
return Result<TemplateScript>.Failure(overrideError);
|
||||
|
||||
// Apply overridable fields
|
||||
existing.Code = proposed.Code;
|
||||
existing.TriggerType = proposed.TriggerType;
|
||||
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
||||
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
|
||||
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
||||
existing.ReturnDefinition = proposed.ReturnDefinition;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
// Name is NOT updated (fixed)
|
||||
|
||||
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(existing);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteScriptAsync(
|
||||
int scriptId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
||||
if (script == null)
|
||||
return Result<bool>.Failure($"Script with ID {scriptId} not found.");
|
||||
|
||||
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
|
||||
if (template?.ParentTemplateId != null)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||
script.Name, template.ParentTemplateId.Value, allTemplates);
|
||||
if (parentMember != null)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot remove script '{script.Name}': it is inherited from parent template.");
|
||||
}
|
||||
|
||||
await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-6: Composition with Recursive Nesting
|
||||
// ========================================================================
|
||||
|
||||
public async Task<Result<TemplateComposition>> AddCompositionAsync(
|
||||
int templateId,
|
||||
int composedTemplateId,
|
||||
string instanceName,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instanceName))
|
||||
return Result<TemplateComposition>.Failure("Instance name is required for composition.");
|
||||
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
var composedTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
||||
if (composedTemplate == null)
|
||||
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
||||
|
||||
// Check for duplicate instance name
|
||||
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
||||
|
||||
// Check composition acyclicity
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
||||
if (cycleError != null)
|
||||
return Result<TemplateComposition>.Failure(cycleError);
|
||||
|
||||
// Check cross-graph cycle
|
||||
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
||||
if (crossCycleError != null)
|
||||
return Result<TemplateComposition>.Failure(crossCycleError);
|
||||
|
||||
var composition = new TemplateComposition(instanceName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
ComposedTemplateId = composedTemplateId
|
||||
};
|
||||
|
||||
// Check for naming collisions with the new composition
|
||||
var testTemplate = CloneTemplateWithNewComposition(template, composition);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
||||
|
||||
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateComposition>.Success(composition);
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteCompositionAsync(
|
||||
int compositionId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
||||
if (composition == null)
|
||||
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
||||
|
||||
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all effective members for a template using canonical (path-qualified) names.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> ResolveTemplateMembersAsync(
|
||||
int templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-8/9/10/11: Override validation (integrated into Update* methods above)
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether overriding a member by canonical name is allowed.
|
||||
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> ValidateOverrideAsync(
|
||||
int templateId,
|
||||
string canonicalName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
||||
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
||||
|
||||
if (member == null)
|
||||
return Result<bool>.Failure($"No member found with canonical name '{canonicalName}'.");
|
||||
|
||||
if (member.IsLocked)
|
||||
return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-12: Naming Collision Detection
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Checks a template for naming collisions across all its members.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> DetectCollisionsAsync(
|
||||
int templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return new[] { $"Template with ID {templateId} not found." };
|
||||
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
return CollisionDetector.DetectCollisions(template, allTemplates);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-13: Graph Acyclicity
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a proposed inheritance or composition change does not create a cycle.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> ValidateAcyclicityAsync(
|
||||
int templateId,
|
||||
int? proposedParentId,
|
||||
int? proposedComposedTemplateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
|
||||
if (proposedParentId.HasValue)
|
||||
{
|
||||
var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
|
||||
if (inheritanceCycle != null)
|
||||
return Result<bool>.Failure(inheritanceCycle);
|
||||
}
|
||||
|
||||
if (proposedComposedTemplateId.HasValue)
|
||||
{
|
||||
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
|
||||
if (compositionCycle != null)
|
||||
return Result<bool>.Failure(compositionCycle);
|
||||
}
|
||||
|
||||
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
|
||||
if (crossCycle != null)
|
||||
return Result<bool>.Failure(crossCycle);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helper methods
|
||||
// ========================================================================
|
||||
|
||||
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
|
||||
{
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
|
||||
if (collisions.Count > 0)
|
||||
return string.Join(" ", collisions);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
|
||||
{
|
||||
var clone = new Template(original.Name)
|
||||
{
|
||||
Id = original.Id,
|
||||
ParentTemplateId = original.ParentTemplateId,
|
||||
Description = original.Description,
|
||||
};
|
||||
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
||||
clone.Attributes.Add(newAttr);
|
||||
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
||||
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
||||
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
|
||||
{
|
||||
var clone = new Template(original.Name)
|
||||
{
|
||||
Id = original.Id,
|
||||
ParentTemplateId = original.ParentTemplateId,
|
||||
Description = original.Description,
|
||||
};
|
||||
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
||||
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
||||
clone.Alarms.Add(newAlarm);
|
||||
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
||||
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
|
||||
{
|
||||
var clone = new Template(original.Name)
|
||||
{
|
||||
Id = original.Id,
|
||||
ParentTemplateId = original.ParentTemplateId,
|
||||
Description = original.Description,
|
||||
};
|
||||
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
||||
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
||||
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
||||
clone.Scripts.Add(newScript);
|
||||
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
|
||||
{
|
||||
var clone = new Template(original.Name)
|
||||
{
|
||||
Id = original.Id,
|
||||
ParentTemplateId = original.ParentTemplateId,
|
||||
Description = original.Description,
|
||||
};
|
||||
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
||||
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
||||
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
||||
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
||||
clone.Compositions.Add(newComp);
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
120
src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs
Normal file
120
src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates script code by attempting to compile it using Roslyn.
|
||||
/// In production, this would compile C# scripts against a stub ScriptApi assembly
|
||||
/// that provides the allowed API surface (attribute read/write, CallScript, CallShared, etc.)
|
||||
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
|
||||
///
|
||||
/// For now, this implementation performs basic syntax validation.
|
||||
/// </summary>
|
||||
public class ScriptCompiler
|
||||
{
|
||||
// Forbidden namespace patterns - scripts must not use these
|
||||
private static readonly string[] ForbiddenPatterns =
|
||||
[
|
||||
"System.IO.",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading.",
|
||||
"System.Reflection.",
|
||||
"System.Net.Sockets.",
|
||||
"System.Net.Http.",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to compile a script and returns success or a compilation error.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# script code.</param>
|
||||
/// <param name="scriptName">The canonical name of the script (for error messages).</param>
|
||||
/// <returns>Success if the script compiles, or Failure with the error message.</returns>
|
||||
public Result<bool> TryCompile(string code, string scriptName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
|
||||
|
||||
// Check for forbidden APIs
|
||||
foreach (var pattern in ForbiddenPatterns)
|
||||
{
|
||||
if (code.Contains(pattern, StringComparison.Ordinal))
|
||||
{
|
||||
return Result<bool>.Failure(
|
||||
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
|
||||
"Scripts cannot use System.IO, Process, Threading, Reflection, or raw network APIs.");
|
||||
}
|
||||
}
|
||||
|
||||
// Basic brace matching validation
|
||||
var braceDepth = 0;
|
||||
var inString = false;
|
||||
var inLineComment = false;
|
||||
var inBlockComment = false;
|
||||
|
||||
for (int i = 0; i < code.Length; i++)
|
||||
{
|
||||
var c = code[i];
|
||||
var next = i + 1 < code.Length ? code[i + 1] : '\0';
|
||||
|
||||
if (inLineComment)
|
||||
{
|
||||
if (c == '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment)
|
||||
{
|
||||
if (c == '*' && next == '/')
|
||||
{
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"' && !inString)
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"' && inString)
|
||||
{
|
||||
// Check for escaped quote
|
||||
if (i > 0 && code[i - 1] != '\\')
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) continue;
|
||||
|
||||
if (c == '{') braceDepth++;
|
||||
else if (c == '}') braceDepth--;
|
||||
|
||||
if (braceDepth < 0)
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace).");
|
||||
}
|
||||
|
||||
if (braceDepth != 0)
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces ({braceDepth} unclosed).");
|
||||
|
||||
if (inBlockComment)
|
||||
return Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment.");
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
303
src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs
Normal file
303
src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic validation rules for a FlattenedConfiguration:
|
||||
/// - CallScript/CallShared targets must reference existing scripts
|
||||
/// - Parameter count and types must match
|
||||
/// - Return type compatibility
|
||||
/// - Trigger operand types: RangeViolation requires numeric attribute
|
||||
/// - On-trigger script must exist
|
||||
/// - Instance scripts cannot call alarm on-trigger scripts
|
||||
/// </summary>
|
||||
public class SemanticValidator
|
||||
{
|
||||
// Known numeric data types for RangeViolation trigger type validation
|
||||
private static readonly HashSet<string> NumericDataTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Int32", "Float", "Double"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs all semantic validation rules.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to validate.</param>
|
||||
/// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
|
||||
public ValidationResult Validate(
|
||||
FlattenedConfiguration configuration,
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
var scriptNames = new HashSet<string>(
|
||||
configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
var sharedScriptNames = new HashSet<string>(
|
||||
(sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
var attributeMap = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
foreach (var a in configuration.Attributes)
|
||||
{
|
||||
// Skip duplicates — naming collisions are reported separately
|
||||
attributeMap.TryAdd(a.CanonicalName, a);
|
||||
}
|
||||
|
||||
// Collect alarm on-trigger script names for cross-call violation checks
|
||||
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName))
|
||||
alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName);
|
||||
}
|
||||
|
||||
// Build parameter maps for call target validation
|
||||
var scriptParamMap = BuildParameterMap(configuration.Scripts);
|
||||
var sharedParamMap = BuildParameterMap(sharedScripts ?? []);
|
||||
var scriptReturnMap = BuildReturnMap(configuration.Scripts);
|
||||
var sharedReturnMap = BuildReturnMap(sharedScripts ?? []);
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
var callTargets = ExtractCallTargets(script.Code);
|
||||
|
||||
foreach (var call in callTargets)
|
||||
{
|
||||
if (call.IsShared)
|
||||
{
|
||||
// CallShared targets must reference existing shared scripts
|
||||
if (!sharedScriptNames.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
|
||||
$"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// CallScript targets must reference existing instance scripts
|
||||
if (!scriptNames.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
|
||||
$"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
else
|
||||
{
|
||||
ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
|
||||
|
||||
// Instance scripts cannot call alarm on-trigger scripts
|
||||
if (alarmOnTriggerScripts.Contains(call.TargetName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation,
|
||||
$"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate alarm trigger operand types
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
// RangeViolation requires numeric attribute
|
||||
if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
{
|
||||
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
|
||||
{
|
||||
if (!NumericDataTypes.Contains(attr.DataType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On-trigger script must exist
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
|
||||
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound,
|
||||
$"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static void ValidateCallParameters(
|
||||
string callerName,
|
||||
CallTarget call,
|
||||
Dictionary<string, List<string>> paramMap,
|
||||
List<ValidationEntry> errors)
|
||||
{
|
||||
if (!paramMap.TryGetValue(call.TargetName, out var expectedParams))
|
||||
return;
|
||||
|
||||
if (call.ArgumentCount != expectedParams.Count)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
|
||||
$"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
|
||||
callerName));
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
|
||||
{
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parameters = ParseParameterDefinitions(script.ParameterDefinitions);
|
||||
result[script.CanonicalName] = parameters;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
result[script.CanonicalName] = script.ReturnDefinition;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call targets from script code by simple pattern matching.
|
||||
/// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
|
||||
/// </summary>
|
||||
internal static List<CallTarget> ExtractCallTargets(string code)
|
||||
{
|
||||
var results = new List<CallTarget>();
|
||||
|
||||
ExtractCallsOfType(code, "CallScript", false, results);
|
||||
ExtractCallsOfType(code, "CallShared", true, results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void ExtractCallsOfType(string code, string methodName, bool isShared, List<CallTarget> results)
|
||||
{
|
||||
var searchPattern = methodName + "(";
|
||||
int pos = 0;
|
||||
|
||||
while (pos < code.Length)
|
||||
{
|
||||
var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal);
|
||||
if (idx < 0) break;
|
||||
|
||||
var argsStart = idx + searchPattern.Length;
|
||||
var target = ExtractStringArgument(code, argsStart);
|
||||
if (target != null)
|
||||
{
|
||||
var argCount = CountArguments(code, argsStart);
|
||||
results.Add(new CallTarget
|
||||
{
|
||||
TargetName = target,
|
||||
IsShared = isShared,
|
||||
ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters
|
||||
});
|
||||
}
|
||||
|
||||
pos = argsStart;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractStringArgument(string code, int startPos)
|
||||
{
|
||||
// Skip whitespace
|
||||
var pos = startPos;
|
||||
while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++;
|
||||
|
||||
if (pos >= code.Length) return null;
|
||||
|
||||
// Expect a quote
|
||||
var quote = code[pos];
|
||||
if (quote != '"' && quote != '\'') return null;
|
||||
|
||||
pos++;
|
||||
var nameStart = pos;
|
||||
while (pos < code.Length && code[pos] != quote) pos++;
|
||||
|
||||
if (pos >= code.Length) return null;
|
||||
|
||||
return code[nameStart..pos];
|
||||
}
|
||||
|
||||
private static int CountArguments(string code, int startPos)
|
||||
{
|
||||
var depth = 1;
|
||||
var count = 1; // At least one argument (the name)
|
||||
var pos = startPos;
|
||||
|
||||
while (pos < code.Length && depth > 0)
|
||||
{
|
||||
switch (code[pos])
|
||||
{
|
||||
case '(':
|
||||
depth++;
|
||||
break;
|
||||
case ')':
|
||||
depth--;
|
||||
break;
|
||||
case ',' when depth == 1:
|
||||
count++;
|
||||
break;
|
||||
case '"':
|
||||
case '\'':
|
||||
// Skip string literals
|
||||
var quote = code[pos];
|
||||
pos++;
|
||||
while (pos < code.Length && code[pos] != quote)
|
||||
{
|
||||
if (code[pos] == '\\') pos++; // Skip escaped chars
|
||||
pos++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
internal record CallTarget
|
||||
{
|
||||
public string TargetName { get; init; } = string.Empty;
|
||||
public bool IsShared { get; init; }
|
||||
public int ArgumentCount { get; init; }
|
||||
}
|
||||
}
|
||||
235
src/ScadaLink.TemplateEngine/Validation/ValidationService.cs
Normal file
235
src/ScadaLink.TemplateEngine/Validation/ValidationService.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
|
||||
/// before deployment. Also available on-demand (same logic, no deployment trigger).
|
||||
///
|
||||
/// Validation checks:
|
||||
/// 1. Flattening success (no empty configuration)
|
||||
/// 2. No naming collisions
|
||||
/// 3. Script compilation (via ScriptCompiler)
|
||||
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
|
||||
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
|
||||
/// 6. Connection binding completeness (all data-sourced attributes must have a binding)
|
||||
/// 7. Does NOT verify tag path resolution on devices
|
||||
/// </summary>
|
||||
public class ValidationService
|
||||
{
|
||||
private readonly SemanticValidator _semanticValidator;
|
||||
private readonly ScriptCompiler _scriptCompiler;
|
||||
|
||||
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
|
||||
{
|
||||
_semanticValidator = semanticValidator;
|
||||
_scriptCompiler = scriptCompiler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience constructor that creates default dependencies.
|
||||
/// </summary>
|
||||
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the full validation pipeline on a flattened configuration.
|
||||
/// </summary>
|
||||
public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var results = new List<ValidationResult>
|
||||
{
|
||||
ValidateFlatteningSuccess(configuration),
|
||||
ValidateNamingCollisions(configuration),
|
||||
ValidateScriptCompilation(configuration),
|
||||
ValidateAlarmTriggerReferences(configuration),
|
||||
ValidateScriptTriggerReferences(configuration),
|
||||
ValidateConnectionBindingCompleteness(configuration),
|
||||
_semanticValidator.Validate(configuration, sharedScripts)
|
||||
};
|
||||
|
||||
return ValidationResult.Merge(results.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that flattening produced a non-empty configuration.
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
|
||||
"Instance unique name is missing."));
|
||||
|
||||
if (configuration.Attributes.Count == 0 &&
|
||||
configuration.Alarms.Count == 0 &&
|
||||
configuration.Scripts.Count == 0)
|
||||
{
|
||||
return new ValidationResult
|
||||
{
|
||||
Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
|
||||
"Flattened configuration contains no attributes, alarms, or scripts.")]
|
||||
};
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that there are no naming collisions across entity types.
|
||||
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
|
||||
CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
|
||||
CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all scripts compile successfully using the ScriptCompiler.
|
||||
/// </summary>
|
||||
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
|
||||
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that alarm trigger configurations reference existing attributes.
|
||||
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var attributeNames = new HashSet<string>(
|
||||
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
continue;
|
||||
|
||||
var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && !attributeNames.Contains(attrName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
|
||||
$"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that script trigger configurations reference existing attributes.
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var attributeNames = new HashSet<string>(
|
||||
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
||||
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
|
||||
continue;
|
||||
|
||||
var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
|
||||
if (attrName != null && !attributeNames.Contains(attrName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
|
||||
$"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? new ValidationResult { Errors = errors }
|
||||
: ValidationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all data-sourced attributes have connection bindings.
|
||||
/// </summary>
|
||||
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
|
||||
foreach (var attr in configuration.Attributes)
|
||||
{
|
||||
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
|
||||
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
|
||||
attr.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static void CheckDuplicates<T>(
|
||||
IReadOnlyList<T> items,
|
||||
Func<T, string> getName,
|
||||
string entityType,
|
||||
List<ValidationEntry> errors)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in items)
|
||||
{
|
||||
var name = getName(item);
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
|
||||
$"{entityType} naming collision: '{name}' appears more than once.",
|
||||
name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
|
||||
return prop.GetString();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON, ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
114
tests/ScadaLink.TemplateEngine.Tests/CollisionDetectorTests.cs
Normal file
114
tests/ScadaLink.TemplateEngine.Tests/CollisionDetectorTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class CollisionDetectorTests
|
||||
{
|
||||
// ========================================================================
|
||||
// WP-12: Naming Collision Detection
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_NoCollisions_ReturnsEmpty()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("Speed") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp") { Id = 1, TemplateId = 1, TriggerType = AlarmTriggerType.ValueMatch });
|
||||
|
||||
var all = new List<Template> { template };
|
||||
var collisions = CollisionDetector.DetectCollisions(template, all);
|
||||
|
||||
Assert.Empty(collisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_DifferentModulesNoPrefixCollision_ReturnsEmpty()
|
||||
{
|
||||
// Two composed modules with same member name but different instance names
|
||||
var moduleA = new Template("ModuleA") { Id = 2 };
|
||||
moduleA.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float });
|
||||
|
||||
var moduleB = new Template("ModuleB") { Id = 3 };
|
||||
moduleB.Attributes.Add(new TemplateAttribute("Value") { Id = 11, TemplateId = 3, DataType = DataType.Float });
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
template.Compositions.Add(new TemplateComposition("modB") { Id = 2, TemplateId = 1, ComposedTemplateId = 3 });
|
||||
|
||||
var all = new List<Template> { template, moduleA, moduleB };
|
||||
var collisions = CollisionDetector.DetectCollisions(template, all);
|
||||
|
||||
// modA.Value and modB.Value are different canonical names => no collision
|
||||
Assert.Empty(collisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_DirectAndComposedNameCollision_ReturnsCollision()
|
||||
{
|
||||
// Template has a direct attribute "Speed"
|
||||
// Composed module also has an attribute that would produce canonical name "Speed"
|
||||
// This happens when a module's member has no prefix collision — actually
|
||||
// composed members always have a prefix so this shouldn't collide.
|
||||
// But a direct member "modA.Value" would collide with modA.Value from composition.
|
||||
// Let's test: direct attr named "modA.Value" and composition modA with member "Value"
|
||||
|
||||
var module = new Template("Module") { Id = 2 };
|
||||
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float });
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("modA.Value") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
||||
template.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { template, module };
|
||||
var collisions = CollisionDetector.DetectCollisions(template, all);
|
||||
|
||||
Assert.NotEmpty(collisions);
|
||||
Assert.Contains(collisions, c => c.Contains("modA.Value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_NestedComposition_ReturnsCorrectCanonicalNames()
|
||||
{
|
||||
// Inner module
|
||||
var inner = new Template("Inner") { Id = 3 };
|
||||
inner.Attributes.Add(new TemplateAttribute("Pressure") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
||||
|
||||
// Outer module composes inner
|
||||
var outer = new Template("Outer") { Id = 2 };
|
||||
outer.Compositions.Add(new TemplateComposition("inner1") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
|
||||
// Main template composes outer
|
||||
var main = new Template("Main") { Id = 1 };
|
||||
main.Compositions.Add(new TemplateComposition("outer1") { Id = 2, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { main, outer, inner };
|
||||
var collisions = CollisionDetector.DetectCollisions(main, all);
|
||||
|
||||
// No collision, just checking it doesn't crash on nested compositions
|
||||
Assert.Empty(collisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCollisions_InheritedMembersCollideWithComposed_ReturnsCollision()
|
||||
{
|
||||
// Parent has a direct attribute "modA.Temp"
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
parent.Attributes.Add(new TemplateAttribute("modA.Temp") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
||||
|
||||
// Module has attribute "Temp"
|
||||
var module = new Template("Module") { Id = 3 };
|
||||
module.Attributes.Add(new TemplateAttribute("Temp") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
||||
|
||||
// Child inherits from parent and composes module as "modA"
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
child.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
|
||||
var all = new List<Template> { parent, child, module };
|
||||
var collisions = CollisionDetector.DetectCollisions(child, all);
|
||||
|
||||
// "modA.Temp" from parent and "modA.Temp" from composed module
|
||||
Assert.NotEmpty(collisions);
|
||||
Assert.Contains(collisions, c => c.Contains("modA.Temp"));
|
||||
}
|
||||
}
|
||||
156
tests/ScadaLink.TemplateEngine.Tests/CycleDetectorTests.cs
Normal file
156
tests/ScadaLink.TemplateEngine.Tests/CycleDetectorTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class CycleDetectorTests
|
||||
{
|
||||
// ========================================================================
|
||||
// WP-13: Inheritance cycle detection
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DetectInheritanceCycle_SelfInheritance_ReturnsCycle()
|
||||
{
|
||||
var template = new Template("A") { Id = 1 };
|
||||
var all = new List<Template> { template };
|
||||
|
||||
var result = CycleDetector.DetectInheritanceCycle(1, 1, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("itself", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectInheritanceCycle_DirectCycle_ReturnsCycle()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||
var all = new List<Template> { templateA, templateB };
|
||||
|
||||
// A tries to inherit from B (B already inherits from A)
|
||||
var result = CycleDetector.DetectInheritanceCycle(1, 2, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectInheritanceCycle_ThreeNodeCycle_ReturnsCycle()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||
var templateC = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||
var all = new List<Template> { templateA, templateB, templateC };
|
||||
|
||||
// A tries to inherit from C (C -> B -> A creates a cycle)
|
||||
var result = CycleDetector.DetectInheritanceCycle(1, 3, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectInheritanceCycle_NoCycle_ReturnsNull()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = null };
|
||||
var all = new List<Template> { templateA, templateB };
|
||||
|
||||
var result = CycleDetector.DetectInheritanceCycle(2, 1, all);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-13: Composition cycle detection
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DetectCompositionCycle_SelfComposition_ReturnsCycle()
|
||||
{
|
||||
var template = new Template("A") { Id = 1 };
|
||||
var all = new List<Template> { template };
|
||||
|
||||
var result = CycleDetector.DetectCompositionCycle(1, 1, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("compose itself", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCompositionCycle_DirectCycle_ReturnsCycle()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1 };
|
||||
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
var all = new List<Template> { templateA, templateB };
|
||||
|
||||
// B tries to compose A (A already composes B)
|
||||
var result = CycleDetector.DetectCompositionCycle(2, 1, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCompositionCycle_TransitiveCycle_ReturnsCycle()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1 };
|
||||
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
var templateC = new Template("C") { Id = 3 };
|
||||
var all = new List<Template> { templateA, templateB, templateC };
|
||||
|
||||
// C tries to compose A => C -> A -> B -> C
|
||||
var result = CycleDetector.DetectCompositionCycle(3, 1, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCompositionCycle_NoCycle_ReturnsNull()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1 };
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
var all = new List<Template> { templateA, templateB };
|
||||
|
||||
var result = CycleDetector.DetectCompositionCycle(1, 2, all);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-13: Cross-graph cycle detection (inheritance + composition)
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DetectCrossGraphCycle_InheritanceCompositionCross_ReturnsCycle()
|
||||
{
|
||||
// A inherits from B, B composes C. If C tries to set parent = A, that's a cross-graph cycle.
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = 2 };
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
var templateC = new Template("C") { Id = 3 };
|
||||
var all = new List<Template> { templateA, templateB, templateC };
|
||||
|
||||
// C tries to add parent = A
|
||||
var result = CycleDetector.DetectCrossGraphCycle(3, 1, null, all);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("Cross-graph cycle", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCrossGraphCycle_NoCycle_ReturnsNull()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1 };
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
var templateC = new Template("C") { Id = 3 };
|
||||
var all = new List<Template> { templateA, templateB, templateC };
|
||||
|
||||
var result = CycleDetector.DetectCrossGraphCycle(3, 1, 2, all);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class DeploymentPackageTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeploymentPackage_JsonSerializable()
|
||||
{
|
||||
var package = new DeploymentPackage
|
||||
{
|
||||
InstanceUniqueName = "PumpStation1",
|
||||
DeploymentId = "dep-abc123",
|
||||
RevisionHash = "sha256:abcdef1234567890",
|
||||
DeployedBy = "admin@company.com",
|
||||
DeployedAtUtc = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
Configuration = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "PumpStation1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Temperature",
|
||||
Value = "25.0",
|
||||
DataType = "Double",
|
||||
BoundDataConnectionId = 100,
|
||||
BoundDataConnectionName = "OPC-Server1",
|
||||
BoundDataConnectionProtocol = "OpcUa",
|
||||
DataSourceReference = "ns=2;s=Temp"
|
||||
}
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "RangeViolation",
|
||||
PriorityLevel = 1
|
||||
}
|
||||
],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Monitor",
|
||||
Code = "var x = Attributes[\"Temperature\"].Value;"
|
||||
}
|
||||
]
|
||||
},
|
||||
PreviousRevisionHash = null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(package);
|
||||
Assert.NotNull(json);
|
||||
Assert.Contains("PumpStation1", json);
|
||||
Assert.Contains("sha256:abcdef1234567890", json);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentPackage>(json);
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("PumpStation1", deserialized.InstanceUniqueName);
|
||||
Assert.Equal("dep-abc123", deserialized.DeploymentId);
|
||||
Assert.Single(deserialized.Configuration.Attributes);
|
||||
Assert.Equal("Temperature", deserialized.Configuration.Attributes[0].CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentPackage_WithDiff_Serializable()
|
||||
{
|
||||
var package = new DeploymentPackage
|
||||
{
|
||||
InstanceUniqueName = "Inst1",
|
||||
DeploymentId = "dep-1",
|
||||
RevisionHash = "sha256:new",
|
||||
DeployedBy = "admin",
|
||||
DeployedAtUtc = DateTimeOffset.UtcNow,
|
||||
Configuration = new FlattenedConfiguration { InstanceUniqueName = "Inst1" },
|
||||
Diff = new ConfigurationDiff
|
||||
{
|
||||
InstanceUniqueName = "Inst1",
|
||||
OldRevisionHash = "sha256:old",
|
||||
NewRevisionHash = "sha256:new",
|
||||
AttributeChanges =
|
||||
[
|
||||
new DiffEntry<ResolvedAttribute>
|
||||
{
|
||||
CanonicalName = "Temp",
|
||||
ChangeType = DiffChangeType.Changed,
|
||||
OldValue = new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
|
||||
NewValue = new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Double" }
|
||||
}
|
||||
]
|
||||
},
|
||||
PreviousRevisionHash = "sha256:old"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(package);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentPackage>(json);
|
||||
|
||||
Assert.NotNull(deserialized?.Diff);
|
||||
Assert.True(deserialized.Diff.HasChanges);
|
||||
Assert.Equal("sha256:old", deserialized.PreviousRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenedConfiguration_DefaultValues()
|
||||
{
|
||||
var config = new FlattenedConfiguration();
|
||||
|
||||
Assert.Equal(string.Empty, config.InstanceUniqueName);
|
||||
Assert.Empty(config.Attributes);
|
||||
Assert.Empty(config.Alarms);
|
||||
Assert.Empty(config.Scripts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class DiffServiceTests
|
||||
{
|
||||
private readonly DiffService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_NullOldConfig_AllAdded()
|
||||
{
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Status", Value = "OK", DataType = "String" }
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation" }
|
||||
],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "Monitor", Code = "// code" }
|
||||
]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(null, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Equal(2, diff.AttributeChanges.Count);
|
||||
Assert.All(diff.AttributeChanges, c => Assert.Equal(DiffChangeType.Added, c.ChangeType));
|
||||
Assert.Single(diff.AlarmChanges);
|
||||
Assert.Single(diff.ScriptChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_IdenticalConfigs_NoChanges()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
|
||||
Alarms = [],
|
||||
Scripts = []
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(config, config);
|
||||
|
||||
Assert.False(diff.HasChanges);
|
||||
Assert.Empty(diff.AttributeChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_AttributeRemoved_DetectedAsRemoved()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Removed", Value = "x", DataType = "String" }
|
||||
]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.AttributeChanges);
|
||||
Assert.Equal(DiffChangeType.Removed, diff.AttributeChanges[0].ChangeType);
|
||||
Assert.Equal("Removed", diff.AttributeChanges[0].CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_AttributeChanged_DetectedAsChanged()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Double" }]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.AttributeChanges);
|
||||
Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType);
|
||||
Assert.Equal("25", diff.AttributeChanges[0].OldValue?.Value);
|
||||
Assert.Equal("50", diff.AttributeChanges[0].NewValue?.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_RevisionHashes_Included()
|
||||
{
|
||||
var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
|
||||
var diff = _sut.ComputeDiff(config, config, "sha256:old", "sha256:new");
|
||||
|
||||
Assert.Equal("sha256:old", diff.OldRevisionHash);
|
||||
Assert.Equal("sha256:new", diff.NewRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_ScriptCodeChange_Detected()
|
||||
{
|
||||
var oldConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts = [new ResolvedScript { CanonicalName = "Script1", Code = "// v1" }]
|
||||
};
|
||||
var newConfig = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts = [new ResolvedScript { CanonicalName = "Script1", Code = "// v2" }]
|
||||
};
|
||||
|
||||
var diff = _sut.ComputeDiff(oldConfig, newConfig);
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
Assert.Single(diff.ScriptChanges);
|
||||
Assert.Equal(DiffChangeType.Changed, diff.ScriptChanges[0].ChangeType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class FlatteningServiceTests
|
||||
{
|
||||
private readonly FlatteningService _sut = new();
|
||||
|
||||
private static Instance CreateInstance(string name = "TestInstance", int templateId = 1, int siteId = 1) =>
|
||||
new(name) { Id = 1, TemplateId = templateId, SiteId = siteId };
|
||||
|
||||
private static Template CreateTemplate(int id, string name, int? parentId = null)
|
||||
{
|
||||
var t = new Template(name) { Id = id, ParentTemplateId = parentId };
|
||||
return t;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_EmptyTemplateChain_ReturnsFailure()
|
||||
{
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_SingleTemplate_ResolvesAttributes()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Temperature") { DataType = DataType.Double, Value = "25.0" });
|
||||
template.Attributes.Add(new TemplateAttribute("Status") { DataType = DataType.String, Value = "OK" });
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Attributes.Count);
|
||||
Assert.Equal("Temperature", result.Value.Attributes[1].CanonicalName); // Sorted
|
||||
Assert.Equal("25.0", result.Value.Attributes[1].Value);
|
||||
Assert.Equal("Status", result.Value.Attributes[0].CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InheritanceChain_DerivedOverridesBase()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Base");
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "100.0" });
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("BaseOnly") { DataType = DataType.String, Value = "base" });
|
||||
|
||||
var childTemplate = CreateTemplate(1, "Child", parentId: 2);
|
||||
childTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "200.0" });
|
||||
childTemplate.Attributes.Add(new TemplateAttribute("ChildOnly") { DataType = DataType.Int32, Value = "42" });
|
||||
|
||||
// Chain: [child, base] — most-derived first
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[childTemplate, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(3, result.Value.Attributes.Count);
|
||||
|
||||
var speed = result.Value.Attributes.First(a => a.CanonicalName == "Speed");
|
||||
Assert.Equal("200.0", speed.Value); // Child's value wins
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_LockedAttribute_NotOverriddenByDerived()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Base");
|
||||
baseTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "locked", IsLocked = true });
|
||||
|
||||
var childTemplate = CreateTemplate(1, "Child", parentId: 2);
|
||||
childTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "overridden" });
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[childTemplate, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var locked = result.Value.Attributes.First(a => a.CanonicalName == "Locked");
|
||||
Assert.Equal("locked", locked.Value); // Base locked value preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceOverride_AppliedToUnlockedAttribute()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Threshold") { DataType = DataType.Double, Value = "50.0" });
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Threshold") { OverrideValue = "75.0" });
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Threshold");
|
||||
Assert.Equal("75.0", attr.Value);
|
||||
Assert.Equal("Override", attr.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceOverride_SkippedForLockedAttribute()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "original", IsLocked = true });
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Locked") { OverrideValue = "changed" });
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Locked");
|
||||
Assert.Equal("original", attr.Value); // Lock honored
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ComposedModule_PathQualifiedNames()
|
||||
{
|
||||
var composedTemplate = CreateTemplate(2, "Pump");
|
||||
composedTemplate.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" });
|
||||
composedTemplate.Scripts.Add(new TemplateScript("Start", "// start") { Id = 10 });
|
||||
|
||||
var parentTemplate = CreateTemplate(1, "Station");
|
||||
parentTemplate.Attributes.Add(new TemplateAttribute("StationName") { DataType = DataType.String, Value = "S1" });
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition>
|
||||
{
|
||||
new("MainPump") { ComposedTemplateId = 2 }
|
||||
}
|
||||
};
|
||||
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [composedTemplate]
|
||||
};
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[parentTemplate],
|
||||
compositions,
|
||||
composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "MainPump.RPM");
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "StationName");
|
||||
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "MainPump.Start");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ConnectionBindings_ResolvedCorrectly()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Temp")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
DataSourceReference = "ns=2;s=Temperature"
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Temp") { DataConnectionId = 100 });
|
||||
|
||||
var connections = new Dictionary<int, DataConnection>
|
||||
{
|
||||
[100] = new("OPC-Server1", "OpcUa") { Id = 100, Configuration = "opc.tcp://localhost:4840" }
|
||||
};
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
connections);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Temp");
|
||||
Assert.Equal(100, attr.BoundDataConnectionId);
|
||||
Assert.Equal("OPC-Server1", attr.BoundDataConnectionName);
|
||||
Assert.Equal("OpcUa", attr.BoundDataConnectionProtocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_Alarms_ResolvedFromChain()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Value.Alarms);
|
||||
Assert.Equal("HighTemp", result.Value.Alarms[0].CanonicalName);
|
||||
Assert.Equal("RangeViolation", result.Value.Alarms[0].TriggerType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceMetadata_SetCorrectly()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
var instance = CreateInstance("MyInstance", templateId: 1, siteId: 5);
|
||||
instance.AreaId = 3;
|
||||
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("MyInstance", result.Value.InstanceUniqueName);
|
||||
Assert.Equal(1, result.Value.TemplateId);
|
||||
Assert.Equal(5, result.Value.SiteId);
|
||||
Assert.Equal(3, result.Value.AreaId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Flattening;
|
||||
|
||||
public class RevisionHashServiceTests
|
||||
{
|
||||
private readonly RevisionHashService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_SameContent_SameHash()
|
||||
{
|
||||
var config1 = CreateConfig("Instance1", "25.0");
|
||||
var config2 = CreateConfig("Instance1", "25.0");
|
||||
|
||||
var hash1 = _sut.ComputeHash(config1);
|
||||
var hash2 = _sut.ComputeHash(config2);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_DifferentContent_DifferentHash()
|
||||
{
|
||||
var config1 = CreateConfig("Instance1", "25.0");
|
||||
var config2 = CreateConfig("Instance1", "50.0");
|
||||
|
||||
var hash1 = _sut.ComputeHash(config1);
|
||||
var hash2 = _sut.ComputeHash(config2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_StartsWithSha256Prefix()
|
||||
{
|
||||
var config = CreateConfig("Instance1", "25.0");
|
||||
var hash = _sut.ComputeHash(config);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_DeterministicAcrossRuns()
|
||||
{
|
||||
// Different GeneratedAtUtc should NOT affect the hash (volatile field excluded)
|
||||
var config1 = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
|
||||
GeneratedAtUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
var config2 = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
|
||||
GeneratedAtUtc = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NullConfig_ThrowsArgumentNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _sut.ComputeHash(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_AttributeOrder_DoesNotAffectHash()
|
||||
{
|
||||
var config1 = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" },
|
||||
new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
var config2 = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" },
|
||||
new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }
|
||||
]
|
||||
};
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||
}
|
||||
|
||||
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
||||
{
|
||||
return new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
TemplateId = 1,
|
||||
SiteId = 1,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = tempValue, DataType = "Double" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
214
tests/ScadaLink.TemplateEngine.Tests/LockEnforcerTests.cs
Normal file
214
tests/ScadaLink.TemplateEngine.Tests/LockEnforcerTests.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class LockEnforcerTests
|
||||
{
|
||||
// ========================================================================
|
||||
// WP-8: Override Granularity
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_LockedAttribute_ReturnsError()
|
||||
{
|
||||
var original = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = true, Value = "0"
|
||||
};
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = true, Value = "100"
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("locked", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_DataTypeChanged_ReturnsError()
|
||||
{
|
||||
var original = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false
|
||||
};
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.String, IsLocked = false // DataType changed!
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("DataType", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_DataSourceReferenceChanged_ReturnsError()
|
||||
{
|
||||
var original = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, DataSourceReference = "tag1"
|
||||
};
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, DataSourceReference = "tag2" // Changed!
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("DataSourceReference", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_ValueAndDescriptionChanged_ReturnsNull()
|
||||
{
|
||||
var original = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, Value = "0", Description = "old"
|
||||
};
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, Value = "100", Description = "new"
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAlarmOverride_LockedAlarm_ReturnsError()
|
||||
{
|
||||
var original = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = true, PriorityLevel = 500
|
||||
};
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = true, PriorityLevel = 600
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("locked", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAlarmOverride_TriggerTypeChanged_ReturnsError()
|
||||
{
|
||||
var original = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false
|
||||
};
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation, IsLocked = false // Changed!
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("TriggerType", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAlarmOverride_OverridableFieldsChanged_ReturnsNull()
|
||||
{
|
||||
var original = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false,
|
||||
PriorityLevel = 500, Description = "old"
|
||||
};
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false,
|
||||
PriorityLevel = 700, Description = "new", TriggerConfiguration = """{"value": 100}"""
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScriptOverride_LockedScript_ReturnsError()
|
||||
{
|
||||
var original = new TemplateScript("OnStart", "code") { IsLocked = true };
|
||||
var proposed = new TemplateScript("OnStart", "new code") { IsLocked = true };
|
||||
|
||||
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("locked", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScriptOverride_NameChanged_ReturnsError()
|
||||
{
|
||||
var original = new TemplateScript("OnStart", "code") { IsLocked = false };
|
||||
var proposed = new TemplateScript("OnStop", "code") { IsLocked = false }; // Name changed!
|
||||
|
||||
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("Name", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateScriptOverride_OverridableFieldsChanged_ReturnsNull()
|
||||
{
|
||||
var original = new TemplateScript("OnStart", "old code") { IsLocked = false };
|
||||
var proposed = new TemplateScript("OnStart", "new code")
|
||||
{
|
||||
IsLocked = false,
|
||||
TriggerType = "Timer",
|
||||
MinTimeBetweenRuns = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-9: Locking Rules
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateLockChange_UnlockLocked_ReturnsError()
|
||||
{
|
||||
var result = LockEnforcer.ValidateLockChange(true, false, "Speed");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("cannot be unlocked", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLockChange_LockUnlocked_ReturnsNull()
|
||||
{
|
||||
var result = LockEnforcer.ValidateLockChange(false, true, "Speed");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLockChange_KeepLocked_ReturnsNull()
|
||||
{
|
||||
var result = LockEnforcer.ValidateLockChange(true, true, "Speed");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateLockChange_KeepUnlocked_ReturnsNull()
|
||||
{
|
||||
var result = LockEnforcer.ValidateLockChange(false, false, "Speed");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Services;
|
||||
|
||||
public class AreaServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
|
||||
private readonly Mock<IAuditService> _auditMock = new();
|
||||
private readonly AreaService _sut;
|
||||
|
||||
public AreaServiceTests()
|
||||
{
|
||||
_sut = new AreaService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateArea_ValidInput_ReturnsSuccess()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.CreateAreaAsync("Building A", 1, null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Building A", result.Value.Name);
|
||||
Assert.Equal(1, result.Value.SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateArea_DuplicateName_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("Building A") { Id = 1, SiteId = 1, ParentAreaId = null }
|
||||
});
|
||||
|
||||
var result = await _sut.CreateAreaAsync("Building A", 1, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateArea_WithParent_ValidatesParentBelongsToSite()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Parent") { Id = 5, SiteId = 99 }); // Different site!
|
||||
|
||||
var result = await _sut.CreateAreaAsync("Child", 1, 5, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("does not belong", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteArea_WithAssignedInstances_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Building A") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>
|
||||
{
|
||||
new("Inst1") { Id = 1, AreaId = 1, SiteId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area> { new("Building A") { Id = 1, SiteId = 1 } });
|
||||
|
||||
var result = await _sut.DeleteAreaAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("instance(s) are assigned", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteArea_WithChildAreas_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Parent") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>());
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("Parent") { Id = 1, SiteId = 1 },
|
||||
new("Child") { Id = 2, SiteId = 1, ParentAreaId = 1 }
|
||||
});
|
||||
|
||||
var result = await _sut.DeleteAreaAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("child areas", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteArea_NoConstraints_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Empty") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>());
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area> { new("Empty") { Id = 1, SiteId = 1 } });
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.DeleteAreaAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteArea_InstancesInDescendants_Blocked()
|
||||
{
|
||||
// Area hierarchy: Area1 -> Area2 -> Area3
|
||||
// Instance assigned to Area3, trying to delete Area1 should be blocked
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
|
||||
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Area>
|
||||
{
|
||||
new("Root") { Id = 1, SiteId = 1 },
|
||||
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
|
||||
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>
|
||||
{
|
||||
new("DeepInstance") { Id = 10, AreaId = 3, SiteId = 1 }
|
||||
});
|
||||
|
||||
var result = await _sut.DeleteAreaAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("instance(s) are assigned", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Services;
|
||||
|
||||
public class InstanceServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
|
||||
private readonly Mock<IAuditService> _auditMock = new();
|
||||
private readonly InstanceService _sut;
|
||||
|
||||
public InstanceServiceTests()
|
||||
{
|
||||
_sut = new InstanceService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateInstance_ValidInput_ReturnsSuccess()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("TestTemplate") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstanceByUniqueNameAsync("Inst1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Instance?)null);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.CreateInstanceAsync("Inst1", 1, 1, null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Inst1", result.Value.UniqueName);
|
||||
Assert.Equal(InstanceState.Disabled, result.Value.State); // Starts disabled
|
||||
_repoMock.Verify(r => r.AddInstanceAsync(It.IsAny<Instance>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateInstance_DuplicateName_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("T") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstanceByUniqueNameAsync("Inst1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Instance("Inst1") { Id = 99 });
|
||||
|
||||
var result = await _sut.CreateInstanceAsync("Inst1", 1, 1, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateInstance_MissingTemplate_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Template?)null);
|
||||
|
||||
var result = await _sut.CreateInstanceAsync("Inst1", 999, 1, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_LockedAttribute_ReturnsFailure()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>
|
||||
{
|
||||
new("LockedAttr") { IsLocked = true }
|
||||
});
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "LockedAttr", "new", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_NonExistentAttribute_ReturnsFailure()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>());
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "Missing", "value", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("does not exist", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_UnlockedAttribute_ReturnsSuccess()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>
|
||||
{
|
||||
new("Threshold") { IsLocked = false }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InstanceAttributeOverride>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "Threshold", "99", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Threshold", result.Value.AttributeName);
|
||||
Assert.Equal("99", result.Value.OverrideValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enable_ExistingInstance_SetsEnabled()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, State = InstanceState.Disabled };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.EnableAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(InstanceState.Enabled, result.Value.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_ExistingInstance_SetsDisabled()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, State = InstanceState.Enabled };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.DisableAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(InstanceState.Disabled, result.Value.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetConnectionBindings_BulkAssignment_Success()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetBindingsByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InstanceConnectionBinding>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var bindings = new List<(string, int)> { ("Temp", 100), ("Pressure", 200) };
|
||||
var result = await _sut.SetConnectionBindingsAsync(1, bindings, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
_repoMock.Verify(r => r.AddInstanceConnectionBindingAsync(It.IsAny<InstanceConnectionBinding>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignToArea_AreaInDifferentSite_ReturnsFailure()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, SiteId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAreaByIdAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Area("WrongSiteArea") { Id = 5, SiteId = 99 });
|
||||
|
||||
var result = await _sut.AssignToAreaAsync(1, 5, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("does not belong", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Services;
|
||||
|
||||
public class SiteServiceTests
|
||||
{
|
||||
private readonly Mock<ISiteRepository> _repoMock = new();
|
||||
private readonly Mock<IAuditService> _auditMock = new();
|
||||
private readonly SiteService _sut;
|
||||
|
||||
public SiteServiceTests()
|
||||
{
|
||||
_sut = new SiteService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSite_ValidInput_ReturnsSuccess()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdentifierAsync("SITE-001", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Site?)null);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.CreateSiteAsync("Plant Alpha", "SITE-001", "Main plant", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Plant Alpha", result.Value.Name);
|
||||
Assert.Equal("SITE-001", result.Value.SiteIdentifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSite_DuplicateIdentifier_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdentifierAsync("SITE-001", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("Existing", "SITE-001") { Id = 1 });
|
||||
|
||||
var result = await _sut.CreateSiteAsync("New", "SITE-001", null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSite_EmptyName_ReturnsFailure()
|
||||
{
|
||||
var result = await _sut.CreateSiteAsync("", "SITE-001", null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSite_WithInstances_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("Plant", "SITE-001") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>
|
||||
{
|
||||
new("Inst1") { Id = 1, SiteId = 1 },
|
||||
new("Inst2") { Id = 2, SiteId = 1 }
|
||||
});
|
||||
|
||||
var result = await _sut.DeleteSiteAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("2 instance(s)", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSite_NoInstances_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("Plant", "SITE-001") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.DeleteSiteAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDataConnection_ValidInput_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.CreateDataConnectionAsync("OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("OPC-Server1", result.Value.Name);
|
||||
Assert.Equal("OpcUa", result.Value.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignConnectionToSite_AlreadyAssigned_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
|
||||
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SiteDataConnectionAssignment { Id = 1, SiteId = 1, DataConnectionId = 100 });
|
||||
|
||||
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already assigned", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignConnectionToSite_Valid_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
|
||||
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SiteDataConnectionAssignment?)null);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSite_ValidInput_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("Old", "S1") { Id = 1 });
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.UpdateSiteAsync(1, "New Name", "New desc", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("New Name", result.Value.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Services;
|
||||
|
||||
public class TemplateDeletionServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
|
||||
private readonly TemplateDeletionService _sut;
|
||||
|
||||
public TemplateDeletionServiceTests()
|
||||
{
|
||||
_sut = new TemplateDeletionService(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_NoReferences_ReturnsSuccess()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Orphan") { Id = 1 });
|
||||
_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> { new("Orphan") { Id = 1 } });
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_WithInstances_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Used") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>
|
||||
{
|
||||
new("Inst1") { Id = 1 },
|
||||
new("Inst2") { Id = 2 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { new("Used") { Id = 1 } });
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("2 instance(s)", result.Error);
|
||||
Assert.Contains("Inst1", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_WithChildTemplates_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Base") { Id = 1 });
|
||||
_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>
|
||||
{
|
||||
new("Base") { Id = 1 },
|
||||
new("Child") { Id = 2, ParentTemplateId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("child template(s)", result.Error);
|
||||
Assert.Contains("Child", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Module") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance>());
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
new("Module") { Id = 1 },
|
||||
new("Composer") { Id = 2 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>
|
||||
{
|
||||
new("PumpModule") { ComposedTemplateId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("compose it", result.Error);
|
||||
Assert.Contains("Composer", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_NotFound_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Template?)null);
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(999);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_AllConstraintsMet_Deletes()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Safe") { Id = 1 });
|
||||
_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> { new("Safe") { Id = 1 } });
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.DeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteTemplate_MultipleConstraints_AllErrorsReported()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Template("Busy") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance> { new("Inst1") { Id = 1 } });
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
new("Busy") { Id = 1 },
|
||||
new("Child") { Id = 2, ParentTemplateId = 1 },
|
||||
new("Composer") { Id = 3 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>
|
||||
{
|
||||
new("Module") { ComposedTemplateId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
// All three constraint types should be mentioned
|
||||
Assert.Contains("instance(s)", result.Error);
|
||||
Assert.Contains("child template(s)", result.Error);
|
||||
Assert.Contains("compose it", result.Error);
|
||||
}
|
||||
}
|
||||
144
tests/ScadaLink.TemplateEngine.Tests/SharedScriptServiceTests.cs
Normal file
144
tests/ScadaLink.TemplateEngine.Tests/SharedScriptServiceTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class SharedScriptServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
||||
private readonly Mock<IAuditService> _auditMock;
|
||||
private readonly SharedScriptService _service;
|
||||
|
||||
public SharedScriptServiceTests()
|
||||
{
|
||||
_repoMock = new Mock<ITemplateEngineRepository>();
|
||||
_auditMock = new Mock<IAuditService>();
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
_service = new SharedScriptService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SharedScript?)null);
|
||||
|
||||
var result = await _service.CreateSharedScriptAsync(
|
||||
"Helpers", "public static int Add(int a, int b) { return a + b; }", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Helpers", result.Value.Name);
|
||||
_repoMock.Verify(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_EmptyName_Fails()
|
||||
{
|
||||
var result = await _service.CreateSharedScriptAsync("", "code", null, null, "admin");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("name is required", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_EmptyCode_Fails()
|
||||
{
|
||||
var result = await _service.CreateSharedScriptAsync("Test", "", null, null, "admin");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("code is required", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_DuplicateName_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SharedScript("Helpers", "existing code"));
|
||||
|
||||
var result = await _service.CreateSharedScriptAsync("Helpers", "new code", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSharedScript_UnbalancedBraces_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Bad", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SharedScript?)null);
|
||||
|
||||
var result = await _service.CreateSharedScriptAsync("Bad", "public void Run() {", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("Syntax error", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSharedScript_Success()
|
||||
{
|
||||
var existing = new SharedScript("Helpers", "old code") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
|
||||
var result = await _service.UpdateSharedScriptAsync(1, "return 42;", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("return 42;", result.Value.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSharedScript_NotFound_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((SharedScript?)null);
|
||||
|
||||
var result = await _service.UpdateSharedScriptAsync(999, "code", null, null, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSharedScript_Success()
|
||||
{
|
||||
var existing = new SharedScript("Helpers", "code") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
|
||||
var result = await _service.DeleteSharedScriptAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteSharedScriptAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSharedScript_NotFound_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((SharedScript?)null);
|
||||
|
||||
var result = await _service.DeleteSharedScriptAsync(999, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
// Syntax validation unit tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("return 42;", null)]
|
||||
[InlineData("public void Run() { }", null)]
|
||||
[InlineData("var x = new int[] { 1, 2, 3 };", null)]
|
||||
[InlineData("if (a > b) { return a; } else { return b; }", null)]
|
||||
public void ValidateSyntax_ValidCode_ReturnsNull(string code, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SharedScriptService.ValidateSyntax(code));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("public void Run() {")]
|
||||
[InlineData("return a + b);")]
|
||||
[InlineData("var x = new int[] { 1, 2 ;")]
|
||||
public void ValidateSyntax_InvalidCode_ReturnsError(string code)
|
||||
{
|
||||
var result = SharedScriptService.ValidateSyntax(code);
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("Syntax error", result);
|
||||
}
|
||||
}
|
||||
177
tests/ScadaLink.TemplateEngine.Tests/TemplateResolverTests.cs
Normal file
177
tests/ScadaLink.TemplateEngine.Tests/TemplateResolverTests.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class TemplateResolverTests
|
||||
{
|
||||
// ========================================================================
|
||||
// WP-7: Path-Qualified Canonical Naming
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_DirectMembers_NoPrefix()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("Speed") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp") { Id = 1, TemplateId = 1, TriggerType = AlarmTriggerType.ValueMatch });
|
||||
template.Scripts.Add(new TemplateScript("OnStart", "code") { Id = 1, TemplateId = 1 });
|
||||
|
||||
var all = new List<Template> { template };
|
||||
var members = TemplateResolver.ResolveAllMembers(1, all);
|
||||
|
||||
Assert.Equal(3, members.Count);
|
||||
Assert.Contains(members, m => m.CanonicalName == "Speed" && m.MemberType == "Attribute");
|
||||
Assert.Contains(members, m => m.CanonicalName == "HighTemp" && m.MemberType == "Alarm");
|
||||
Assert.Contains(members, m => m.CanonicalName == "OnStart" && m.MemberType == "Script");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_ComposedModule_PrefixedNames()
|
||||
{
|
||||
var module = new Template("Module") { Id = 2 };
|
||||
module.Attributes.Add(new TemplateAttribute("Pressure") { Id = 10, TemplateId = 2, DataType = DataType.Float });
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("sensor1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { template, module };
|
||||
var members = TemplateResolver.ResolveAllMembers(1, all);
|
||||
|
||||
Assert.Single(members);
|
||||
Assert.Equal("sensor1.Pressure", members[0].CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_NestedComposition_MultiLevelPrefix()
|
||||
{
|
||||
var inner = new Template("Inner") { Id = 3 };
|
||||
inner.Attributes.Add(new TemplateAttribute("Value") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
||||
|
||||
var outer = new Template("Outer") { Id = 2 };
|
||||
outer.Compositions.Add(new TemplateComposition("innerMod") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
|
||||
var main = new Template("Main") { Id = 1 };
|
||||
main.Compositions.Add(new TemplateComposition("outerMod") { Id = 2, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { main, outer, inner };
|
||||
var members = TemplateResolver.ResolveAllMembers(1, all);
|
||||
|
||||
Assert.Single(members);
|
||||
Assert.Equal("outerMod.innerMod.Value", members[0].CanonicalName);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-10: Inheritance Override Scope
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_InheritedMembers_Included()
|
||||
{
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
parent.Attributes.Add(new TemplateAttribute("Speed") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
||||
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
child.Attributes.Add(new TemplateAttribute("ExtraAttr") { Id = 20, TemplateId = 2, DataType = DataType.String });
|
||||
|
||||
var all = new List<Template> { parent, child };
|
||||
var members = TemplateResolver.ResolveAllMembers(2, all);
|
||||
|
||||
Assert.Equal(2, members.Count);
|
||||
Assert.Contains(members, m => m.CanonicalName == "Speed");
|
||||
Assert.Contains(members, m => m.CanonicalName == "ExtraAttr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_ChildOverridesParentMember_UsesChildVersion()
|
||||
{
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
parent.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 10, TemplateId = 1, DataType = DataType.Float, Value = "0"
|
||||
});
|
||||
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
child.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 20, TemplateId = 2, DataType = DataType.Float, Value = "100"
|
||||
});
|
||||
|
||||
var all = new List<Template> { parent, child };
|
||||
var members = TemplateResolver.ResolveAllMembers(2, all);
|
||||
|
||||
// Should have one Speed member, from the child (override)
|
||||
var speedMember = Assert.Single(members, m => m.CanonicalName == "Speed");
|
||||
Assert.Equal(2, speedMember.SourceTemplateId); // Child's version
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAllMembers_InheritedComposedModules_Included()
|
||||
{
|
||||
var module = new Template("Module") { Id = 3 };
|
||||
module.Attributes.Add(new TemplateAttribute("Pressure") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
||||
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
parent.Compositions.Add(new TemplateComposition("sensor1") { Id = 1, TemplateId = 1, ComposedTemplateId = 3 });
|
||||
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
|
||||
var all = new List<Template> { parent, child, module };
|
||||
var members = TemplateResolver.ResolveAllMembers(2, all);
|
||||
|
||||
Assert.Contains(members, m => m.CanonicalName == "sensor1.Pressure");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-11: Composition Override Scope
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void FindMemberByCanonicalName_ComposedMember_Found()
|
||||
{
|
||||
var module = new Template("Module") { Id = 2 };
|
||||
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float, IsLocked = false });
|
||||
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { template, module };
|
||||
var member = TemplateResolver.FindMemberByCanonicalName("mod1.Value", 1, all);
|
||||
|
||||
Assert.NotNull(member);
|
||||
Assert.Equal("mod1.Value", member.CanonicalName);
|
||||
Assert.False(member.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMemberByCanonicalName_LockedComposedMember_ReturnsLocked()
|
||||
{
|
||||
var module = new Template("Module") { Id = 2 };
|
||||
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float, IsLocked = true });
|
||||
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
var all = new List<Template> { template, module };
|
||||
var member = TemplateResolver.FindMemberByCanonicalName("mod1.Value", 1, all);
|
||||
|
||||
Assert.NotNull(member);
|
||||
Assert.True(member.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildInheritanceChain_ThreeLevel_RootFirst()
|
||||
{
|
||||
var grandparent = new Template("GP") { Id = 1 };
|
||||
var parent = new Template("P") { Id = 2, ParentTemplateId = 1 };
|
||||
var child = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||
|
||||
var lookup = new Dictionary<int, Template> { [1] = grandparent, [2] = parent, [3] = child };
|
||||
var chain = TemplateResolver.BuildInheritanceChain(3, lookup);
|
||||
|
||||
Assert.Equal(3, chain.Count);
|
||||
Assert.Equal("GP", chain[0].Name);
|
||||
Assert.Equal("P", chain[1].Name);
|
||||
Assert.Equal("C", chain[2].Name);
|
||||
}
|
||||
}
|
||||
510
tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
Normal file
510
tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
Normal file
@@ -0,0 +1,510 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class TemplateServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
||||
private readonly Mock<IAuditService> _auditMock;
|
||||
private readonly TemplateService _service;
|
||||
|
||||
public TemplateServiceTests()
|
||||
{
|
||||
_repoMock = new Mock<ITemplateEngineRepository>();
|
||||
_auditMock = new Mock<IAuditService>();
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-1: Template CRUD with Inheritance
|
||||
// ========================================================================
|
||||
|
||||
[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);
|
||||
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_EmptyName_Fails()
|
||||
{
|
||||
var result = await _service.CreateTemplateAsync("", null, null, "admin");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value.ParentTemplateId);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[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 } });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("referenced by", result.Error);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("inherited by", result.Error);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("composed by", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-2: Attribute Definitions with Lock Flags
|
||||
// ========================================================================
|
||||
|
||||
[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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-3: Alarm Definitions
|
||||
// ========================================================================
|
||||
|
||||
[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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-4: Script Definitions
|
||||
// ========================================================================
|
||||
|
||||
[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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-5: Shared Script CRUD (see SharedScriptServiceTests)
|
||||
// ========================================================================
|
||||
|
||||
// ========================================================================
|
||||
// WP-6: Composition with Recursive Nesting
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_Success()
|
||||
{
|
||||
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 });
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("myModule", result.Value.InstanceName);
|
||||
Assert.Equal(2, result.Value.ComposedTemplateId);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("compose itself", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-9: Locking Rules
|
||||
// ========================================================================
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-10: Inheritance Override Scope — Cannot remove parent members
|
||||
// ========================================================================
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.DeleteAttributeAsync(20, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("inherited from parent", result.Error);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-13: Graph Acyclicity
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_InheritanceCycle_Fails()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(templateB);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { templateA, templateB });
|
||||
|
||||
// Try to make A inherit from B (B already inherits from A) => cycle
|
||||
var result = await _service.UpdateTemplateAsync(1, "A", null, 2, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_SelfInheritance_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 });
|
||||
|
||||
var result = await _service.UpdateTemplateAsync(1, "Self", null, 1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("itself", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Validation;
|
||||
|
||||
public class ScriptCompilerTests
|
||||
{
|
||||
private readonly ScriptCompiler _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_ValidCode_ReturnsSuccess()
|
||||
{
|
||||
var result = _sut.TryCompile("var x = 1; if (x > 0) { x++; }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_EmptyCode_ReturnsFailure()
|
||||
{
|
||||
var result = _sut.TryCompile("", "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_MismatchedBraces_ReturnsFailure()
|
||||
{
|
||||
var result = _sut.TryCompile("if (true) { x = 1;", "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_UnclosedBlockComment_ReturnsFailure()
|
||||
{
|
||||
var result = _sut.TryCompile("/* this is never closed", "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("comment", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("System.IO.File.ReadAllText(\"x\");")]
|
||||
[InlineData("System.Diagnostics.Process.Start(\"cmd\");")]
|
||||
[InlineData("System.Threading.Thread.Sleep(1000);")]
|
||||
[InlineData("System.Reflection.Assembly.Load(\"x\");")]
|
||||
[InlineData("System.Net.Sockets.TcpClient c;")]
|
||||
[InlineData("System.Net.Http.HttpClient c;")]
|
||||
public void TryCompile_ForbiddenApi_ReturnsFailure(string code)
|
||||
{
|
||||
var result = _sut.TryCompile(code, "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_BracesInStrings_Ignored()
|
||||
{
|
||||
var result = _sut.TryCompile("var s = \"{ not a brace }\";", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_BracesInComments_Ignored()
|
||||
{
|
||||
var result = _sut.TryCompile("// { not a brace\nvar x = 1;", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_BlockCommentWithBraces_Ignored()
|
||||
{
|
||||
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Validation;
|
||||
|
||||
public class SemanticValidatorTests
|
||||
{
|
||||
private readonly SemanticValidator _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptTargetNotFound_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Caller",
|
||||
Code = "CallScript(\"NonExistent\");"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.CallTargetNotFound &&
|
||||
e.Message.Contains("NonExistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptTargetExists_NoError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" },
|
||||
new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallSharedTargetNotFound_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Caller",
|
||||
Code = "CallShared(\"MissingShared\");"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config, sharedScripts: []);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.CallTargetNotFound &&
|
||||
e.Message.Contains("MissingShared"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallSharedTargetExists_NoError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "Caller", Code = "CallShared(\"Utility\");" }
|
||||
]
|
||||
};
|
||||
|
||||
var shared = new List<ResolvedScript>
|
||||
{
|
||||
new() { CanonicalName = "Utility", Code = "// shared" }
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config, shared);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ParameterCountMismatch_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Target",
|
||||
Code = "var x = 1;",
|
||||
ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]"
|
||||
},
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "Caller",
|
||||
Code = "CallScript(\"Target\", 42);" // 1 arg but 2 expected
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RangeViolationOnNonNumeric_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Status", DataType = "String" }
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "BadAlarm",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\"}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RangeViolationOnNumeric_NoError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\"}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OnTriggerScriptNotFound_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts = [new ResolvedScript { CanonicalName = "OtherScript", Code = "var x = 1;" }],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Alarm1",
|
||||
TriggerType = "ValueMatch",
|
||||
OnTriggerScriptCanonicalName = "MissingScript"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.OnTriggerScriptNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InstanceScriptCallsAlarmOnTrigger_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "AlarmHandler", Code = "// alarm handler" },
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "RegularScript",
|
||||
Code = "CallScript(\"AlarmHandler\");"
|
||||
}
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "Alarm1",
|
||||
TriggerType = "ValueMatch",
|
||||
OnTriggerScriptCanonicalName = "AlarmHandler"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CrossCallViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractCallTargets_MultipleCallTypes()
|
||||
{
|
||||
var code = @"
|
||||
var x = CallScript(""Script1"", arg1, arg2);
|
||||
CallShared(""Shared1"");
|
||||
CallScript(""Script2"");
|
||||
";
|
||||
|
||||
var targets = SemanticValidator.ExtractCallTargets(code);
|
||||
|
||||
Assert.Equal(3, targets.Count);
|
||||
Assert.Contains(targets, t => t.TargetName == "Script1" && !t.IsShared && t.ArgumentCount == 2);
|
||||
Assert.Contains(targets, t => t.TargetName == "Shared1" && t.IsShared && t.ArgumentCount == 0);
|
||||
Assert.Contains(targets, t => t.TargetName == "Script2" && !t.IsShared && t.ArgumentCount == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseParameterDefinitions_ValidJson_ReturnsList()
|
||||
{
|
||||
var json = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]";
|
||||
var result = SemanticValidator.ParseParameterDefinitions(json);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("Int32", result[0]);
|
||||
Assert.Equal("String", result[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseParameterDefinitions_NullOrEmpty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
|
||||
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests.Validation;
|
||||
|
||||
public class ValidationServiceTests
|
||||
{
|
||||
private readonly ValidationService _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidConfig_ReturnsSuccess()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
|
||||
Scripts = [new ResolvedScript { CanonicalName = "Monitor", Code = "var x = 1;" }]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyInstanceName_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.FlatteningFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NamingCollision_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" },
|
||||
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Int32" } // Duplicate!
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NamingCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ForbiddenApi_ReturnsCompilationError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "BadScript",
|
||||
Code = "System.IO.File.ReadAllText(\"secret.txt\");"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MismatchedBraces_ReturnsCompilationError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript { CanonicalName = "Bad", Code = "if (true) {" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AlarmReferencesMissingAttribute_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighPressure",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Pressure\"}" // Pressure doesn't exist
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AlarmReferencesExistingAttribute_NoError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\"}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
// Should not have alarm trigger reference errors
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ScriptTriggerReferencesMissingAttribute_ReturnsError()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
|
||||
Scripts =
|
||||
[
|
||||
new ResolvedScript
|
||||
{
|
||||
CanonicalName = "OnChange",
|
||||
Code = "var x = 1;",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Missing\"}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptTriggerReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnboundDataSourceAttribute_ReturnsWarning()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Temp",
|
||||
DataType = "Double",
|
||||
DataSourceReference = "ns=2;s=Temp",
|
||||
BoundDataConnectionId = null // No binding!
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.ConnectionBinding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyConfig_ReturnsWarning()
|
||||
{
|
||||
var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.FlatteningFailure);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user