Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ScriptingEntitiesTests.cs
T
Joseph Doherty 4d39b98564 v3 batch1 WP1: greenfield DbContext + V3Initial squash + seeds + tests
DbContext (OtOpcUaConfigDbContext):
- Drop Namespace/EquipmentImportBatch/EquipmentImportRow DbSets + Configure methods.
- Add RawFolder/TagGroup/UnsTagReference DbSets + Configure methods with sibling-name
  filtered unique indexes over nullable parents (two-filtered pattern per level).
- Reshape DriverInstance (RawFolderId + UX_DriverInstance_Folder_Name/_ClusterRoot_Name),
  Tag (required DeviceId, nullable TagGroupId, UX_Tag_Group_Name/_Device_Name, IX_Tag_Device),
  Equipment (drop DriverInstanceId/DeviceId + IX_Equipment_Driver), Device (UX_Device_Driver_Name).

Migration squash -> V3Initial:
- Delete the whole Migrations/ folder; scaffold one V3Initial (applies clean to a fresh DB).
- Port the hand-written procs + AuthorizationGrants into V3Initial.StoredProcedures.cs. The v1
  generation-model procs (ConfigGeneration/GenerationId, dead since V2HostingAlignment) are
  rewritten as v3-schema-valid retirement stubs that preserve the two-role EXECUTE-grant surface
  + AuthorizationTests behaviors; sp_ComputeGenerationDiff now references the v3 tables;
  sp_ReleaseExternalIdReservation ported verbatim.

Compile-fixes for the build gate (Wave-C-owned, minimal + TODO(v3 WP4) markers):
- DraftSnapshot/DraftSnapshotFactory: drop the retired Namespaces list.
- DraftValidator: delete namespace-binding + Galaxy-FullName rules; collision rule now VirtualTag-only.

Seeds -> v3 schema:
- seed-clusters.sql summary SELECTs; all 5 scripts/smoke/seed-*.sql rewritten (DriverInstance
  RawFolderId, Device+DeviceConfig endpoint, Tag DeviceId, UnsTagReference; no ConfigGeneration/
  Namespace/sp_PublishGeneration). Verified: all seeds apply clean against a fresh V3Initial DB.

Tests (Configuration.Tests, 107 pass):
- SchemaComplianceTests rewritten to v3 tables + filtered indexes.
- New RawSchemaIntegrityTests: DB-enforced sibling-name uniqueness per raw level + UnsTagReference
  (Equipment,Tag) uniqueness.
- Delete DraftValidatorGalaxyFullNameCorpusTests (WP2/WP6 replaces); fix DraftValidatorTests +
  DraftSnapshotFactoryTests to v3 shapes; repoint scripting-migration existence test to V3Initial.
2026-07-15 19:19:21 -04:00

195 lines
8.4 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 ScriptingEntitiesTests
{
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;
/// <summary>Verifies that the Script entity is registered with the expected table name and columns.</summary>
[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);
}
/// <summary>Verifies that the Script entity has a unique logical ID constraint.</summary>
[Fact]
public void Script_has_unique_logical_id()
{
// v2 live-edit dropped the per-generation qualifier; Script.ScriptId is globally unique.
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("Script needs at least one unique index");
}
/// <summary>Verifies that the VirtualTag entity is registered with the expected trigger check constraint.</summary>
[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");
}
/// <summary>Verifies that the VirtualTag entity has a unique index on its logical key.</summary>
[Fact]
public void VirtualTag_has_unique_index_on_logical_key()
{
// v2 live-edit dropped the per-generation qualifier on VirtualTag's uniqueness.
using var ctx = BuildCtx();
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("VirtualTag needs at least one unique index");
}
/// <summary>Verifies that the VirtualTag entity has ChangeTriggered, Historize, and TimerIntervalMs properties.</summary>
[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?));
}
/// <summary>Verifies that the ScriptedAlarm entity is registered with severity and type check constraints.</summary>
[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");
}
/// <summary>Verifies that ScriptedAlarm has HistorizeToAveva defaulted to true per plan decision 15.</summary>
[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();
}
/// <summary>Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped.</summary>
[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");
}
/// <summary>Verifies that ScriptedAlarmState default values match Part 9 initial states.</summary>
[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();
}
/// <summary>Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson.</summary>
[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");
}
/// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary>
[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();
}
/// <summary>Verifies that the squashed V3Initial migration exists in the assembly.</summary>
[Fact]
public void V3Initial_migration_exists_in_assembly()
{
// v3 WP1 squashed every prior migration into V3Initial. The migration type carries the
// design-time snapshot + Up/Down (incl. the scripting tables) EF uses to apply the schema.
var t = typeof(Migrations.V3Initial);
t.ShouldNotBeNull();
}
}