Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHistorianReadOnlyTests.cs
T
Joseph Doherty 6c4d29902e
v2-ci / unit-tests (push) Has been cancelled
fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via
historianTagname re-typed/re-described it on every add and host start.
- TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly):
  HistoryRead from an existing historian tag that OtOpcUa never provisions and never
  records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags
  hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the
  RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid.
  AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the
  runtime parse.
- ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly
  (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not
  sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start.
- Default for an explicit historianTagname stays OWNED: overrides pin owned history
  across renames, a read-only default would silently stop provisioning + recording.

#898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized
Float/Double/Byte/SByte tags were never provisioned. New shared
HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16
(lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips
Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject).

UPGRADE: the first host start after this provisions (upserts) every existing historized
Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set
ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 01:16:24 -05:00

56 lines
2.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// lmxopcua#899: <c>historianReadOnly</c> must be a JSON boolean. The runtime honours only a bool <c>true</c>, so a
/// string <c>"true"</c> would deploy as an OWNED tag whose foreign historian tag then gets provisioned (upserted)
/// and recorded into — the deploy gate refuses it instead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorHistorianReadOnlyTests
{
private static DraftSnapshot Draft(params Tag[] tags) => new()
{
GenerationId = 1, ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "di", ClusterId = "c", Name = "mb", DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [new Device { DeviceId = "dev", DriverInstanceId = "di", Name = "plc", DeviceConfig = "{}" }],
Tags = tags,
};
private static Tag TagRow(string id, string tagConfig) => new()
{
TagId = id, DeviceId = "dev", Name = id, DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
};
[Theory]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":\"true\"}")]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":1}")]
[InlineData("{\"historianReadOnly\":null}")]
public void A_non_boolean_historianReadOnly_is_a_deploy_error(string tagConfig)
{
var errors = DraftValidator.Validate(Draft(TagRow("t-bad", tagConfig)));
errors.ShouldContain(e => e.Code == "HistorianReadOnlyInvalid" && e.Context == "t-bad");
}
[Theory]
[InlineData("{\"isHistorized\":true,\"historianTagname\":\"SysTimeSec\",\"historianReadOnly\":true}")]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":false}")]
[InlineData("{\"isHistorized\":true}")]
public void A_boolean_or_absent_historianReadOnly_passes(string tagConfig)
{
var errors = DraftValidator.Validate(Draft(TagRow("t-ok", tagConfig)));
errors.ShouldNotContain(e => e.Code == "HistorianReadOnlyInvalid");
}
}