Adds the four tables Streams B/C/F consume — Script (generation-scoped source code), VirtualTag (generation-scoped calculated-tag config), ScriptedAlarm (generation-scoped alarm config), and ScriptedAlarmState (logical-id-keyed persistent runtime state). ## New entities (net10, EF Core) - Script — stable logical ScriptId carries across generations; SourceHash is the compile-cache key (matches Core.Scripting's CompiledScriptCache). - VirtualTag — mandatory EquipmentId FK (plan decision #2, unified Equipment tree); ChangeTriggered/TimerIntervalMs + Historize flags; check constraints enforce "at least one trigger" + "timer >= 50ms". - ScriptedAlarm — required AlarmType ('AlarmCondition'/'LimitAlarm'/'OffNormalAlarm'/ 'DiscreteAlarm'); Severity 1..1000 range check; HistorizeToAveva default true per plan decision #15. - ScriptedAlarmState — keyed ONLY on ScriptedAlarmId (NOT generation-scoped) per plan decision #14 — ack state + audit trail must follow alarm identity across Modified generations. CommentsJson has ISJSON check for GxP audit. ## Migration EF-generated 20260420231641_AddPhase7ScriptingTables covers all 4 tables + indexes + check constraints + FKs to ConfigGeneration. sp_PublishGeneration required no changes — it only flips Draft->Published status; the new entities already carry GenerationId so they publish atomically with the rest of the config. ## Tests — 12/12 (design-time model introspection) Phase7ScriptingEntitiesTests covers: table registration, column maxlength + column types, unique indexes (Generation+LogicalId, Generation+EquipmentPath for VirtualTag and ScriptedAlarm), secondary indexes (SourceHash for cache lookup), check constraints (trigger-required, timer-min, severity-range, alarm-type-enum, CommentsJson-IsJson), ScriptedAlarmState PK is alarm-id not generation-scoped, ScriptedAlarm defaults (HistorizeToAveva=true, Retain=true, Severity=500, Enabled=true), DbSets wired, and the generated migration type exists for rollforward.
185 lines
7.2 KiB
C#
185 lines
7.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Metadata;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
|
|
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
|
|
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
|
|
/// the plan decisions call for. Introspection only — no SQL Server required.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class Phase7ScriptingEntitiesTests
|
|
{
|
|
private static OtOpcUaConfigDbContext BuildCtx()
|
|
{
|
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseSqlServer("Server=(local);Database=dummy;Integrated Security=true") // not connected
|
|
.Options;
|
|
return new OtOpcUaConfigDbContext(options);
|
|
}
|
|
|
|
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
|
|
=> ctx.GetService<IDesignTimeModel>().Model;
|
|
|
|
[Fact]
|
|
public void Script_entity_registered_with_expected_table_and_columns()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
|
|
|
entity.GetTableName().ShouldBe("Script");
|
|
entity.FindProperty(nameof(Script.ScriptRowId)).ShouldNotBeNull();
|
|
entity.FindProperty(nameof(Script.ScriptId)).ShouldNotBeNull()
|
|
.GetMaxLength().ShouldBe(64);
|
|
entity.FindProperty(nameof(Script.SourceCode)).ShouldNotBeNull()
|
|
.GetColumnType().ShouldBe("nvarchar(max)");
|
|
entity.FindProperty(nameof(Script.SourceHash)).ShouldNotBeNull()
|
|
.GetMaxLength().ShouldBe(64);
|
|
entity.FindProperty(nameof(Script.Language)).ShouldNotBeNull()
|
|
.GetMaxLength().ShouldBe(16);
|
|
}
|
|
|
|
[Fact]
|
|
public void Script_has_unique_logical_id_per_generation()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
|
entity.GetIndexes().ShouldContain(
|
|
i => i.IsUnique && i.GetDatabaseName() == "UX_Script_Generation_LogicalId");
|
|
entity.GetIndexes().ShouldContain(
|
|
i => i.GetDatabaseName() == "IX_Script_Generation_SourceHash");
|
|
}
|
|
|
|
[Fact]
|
|
public void VirtualTag_entity_registered_with_trigger_check_constraint()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = DesignModel(ctx).FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
|
entity.GetTableName().ShouldBe("VirtualTag");
|
|
|
|
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
|
checks.ShouldContain("CK_VirtualTag_Trigger_AtLeastOne");
|
|
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
|
|
}
|
|
|
|
[Fact]
|
|
public void VirtualTag_enforces_unique_name_per_Equipment()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
|
entity.GetIndexes().ShouldContain(
|
|
i => i.IsUnique && i.GetDatabaseName() == "UX_VirtualTag_Generation_EquipmentPath");
|
|
}
|
|
|
|
[Fact]
|
|
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
|
entity.FindProperty(nameof(VirtualTag.ChangeTriggered)).ShouldNotBeNull()
|
|
.ClrType.ShouldBe(typeof(bool));
|
|
entity.FindProperty(nameof(VirtualTag.Historize)).ShouldNotBeNull()
|
|
.ClrType.ShouldBe(typeof(bool));
|
|
entity.FindProperty(nameof(VirtualTag.TimerIntervalMs)).ShouldNotBeNull()
|
|
.ClrType.ShouldBe(typeof(int?));
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarm)).ShouldNotBeNull();
|
|
entity.GetTableName().ShouldBe("ScriptedAlarm");
|
|
|
|
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
|
checks.ShouldContain("CK_ScriptedAlarm_Severity_Range");
|
|
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
|
|
{
|
|
// Defaults live on the CLR default assignment — verify the initializer.
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmId = "a1",
|
|
EquipmentId = "eq1",
|
|
Name = "n",
|
|
AlarmType = "LimitAlarm",
|
|
MessageTemplate = "m",
|
|
PredicateScriptId = "s1",
|
|
};
|
|
alarm.HistorizeToAveva.ShouldBeTrue();
|
|
alarm.Retain.ShouldBeTrue();
|
|
alarm.Severity.ShouldBe(500);
|
|
alarm.Enabled.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
|
entity.GetTableName().ShouldBe("ScriptedAlarmState");
|
|
|
|
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
|
|
pk.Properties.Count.ShouldBe(1);
|
|
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
|
|
|
|
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
|
|
entity.FindProperty("GenerationId").ShouldBeNull(
|
|
"ack state follows alarm identity across generations");
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
|
{
|
|
var state = new ScriptedAlarmState
|
|
{
|
|
ScriptedAlarmId = "a1",
|
|
EnabledState = "Enabled",
|
|
AckedState = "Unacknowledged",
|
|
ConfirmedState = "Unconfirmed",
|
|
ShelvingState = "Unshelved",
|
|
};
|
|
state.CommentsJson.ShouldBe("[]");
|
|
state.LastAckUser.ShouldBeNull();
|
|
state.LastAckUtc.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
|
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
|
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
|
}
|
|
|
|
[Fact]
|
|
public void All_new_entities_exposed_via_DbSet()
|
|
{
|
|
using var ctx = BuildCtx();
|
|
ctx.Scripts.ShouldNotBeNull();
|
|
ctx.VirtualTags.ShouldNotBeNull();
|
|
ctx.ScriptedAlarms.ShouldNotBeNull();
|
|
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
|
|
{
|
|
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
|
|
// apply the schema. Missing = schema won't roll forward in deployments.
|
|
var t = typeof(Migrations.AddPhase7ScriptingTables);
|
|
t.ShouldNotBeNull();
|
|
}
|
|
}
|