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; /// /// Verifies the Phase 7 Stream E entities (, , /// , ) 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. /// [Trait("Category", "Unit")] public sealed class Phase7ScriptingEntitiesTests { private static OtOpcUaConfigDbContext BuildCtx() { var options = new DbContextOptionsBuilder() .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().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(); } }