Merge feature/multivalue-attribute: structured multi-value (List) attributes
First-class DataType.List (homogeneous list of a scalar ElementDataType) round-tripping through authoring, flatten, site runtime, OPC UA read+write, gRPC streaming, validation, management API, CLI, Transport bundles, and Central UI (TemplateEdit + InstanceConfigure). Canonical AttributeValueCodec (JSON, invariant culture); in-memory typed List<T> vs persisted/streamed JSON; idempotent migration; element type fixed by base. 255 feature-targeted tests; full solution builds 0/0. Plan: docs/plans/2026-06-16-multivalue-attribute.md.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# Structured Multi-Value Attribute — Design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Status:** Implemented (MV-1 … MV-15, branch `feature/multivalue-attribute`) — full solution builds clean; feature-targeted tests green across Commons, TemplateEngine, SiteRuntime, DataConnectionLayer, Communication, Transport, ManagementService, CLI, and CentralUI.
|
||||
**Branch:** `feature/multivalue-attribute`
|
||||
|
||||
## Problem
|
||||
|
||||
ScadaBridge's `DataType` enum (`Boolean·Int32·Float·Double·String·DateTime·Binary`) has no
|
||||
collection type. An attribute is persisted as a single `string? Value` plus a `DataType`
|
||||
discriminator. So a Galaxy/object attribute that is conceptually a list of values — e.g.
|
||||
`MoveInWorkOrderNumbers`, `MoveInPartNumbers` (string arrays arriving via the
|
||||
`IpsenMESMoveIn` inbound-API method) — can only be stored by collapsing it to a single
|
||||
`DataType.String` blob. We want these stored as a **first-class structured multi-value
|
||||
attribute** that round-trips through every path.
|
||||
|
||||
## Scope (decided)
|
||||
|
||||
- **Element types:** a *homogeneous* list of any existing **scalar** `DataType` —
|
||||
`String, Int32, Float, Double, Boolean, DateTime`. **Excluded:** `Binary` elements and
|
||||
nested lists (no lists-of-lists, no lists-of-objects).
|
||||
- **Lifecycle paths — all four in scope:**
|
||||
1. Script writes the attribute at runtime and reads it back (the `IpsenMESMoveIn` case);
|
||||
persists as a static attribute (site SQLite, survives failover).
|
||||
2. Statically authored default in a template, optionally overridden per instance, via
|
||||
Central UI and CLI.
|
||||
3. Read in from an OPC UA array node (DCL subscription → live attribute value).
|
||||
4. Written out to an OPC UA array node via the DCL write path.
|
||||
|
||||
## Decision summary (defaults locked in)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Type modeling | One `DataType.List` member + nullable `ElementDataType` companion |
|
||||
| Value encoding | JSON array, invariant culture (round-trippable) |
|
||||
| Display encoding | `ValueFormatter.FormatDisplayValue` stays comma-joined, display-only |
|
||||
| Element types | 6 scalars; no `Binary`, no nesting |
|
||||
| Override granularity | Whole-list replacement (no per-element override) |
|
||||
| Element type mutability | Fixed by base attribute; not overridable on derived/instance |
|
||||
| Bad OPC UA element | Coerce/validate; mismatch → Bad quality + log (non-fatal) |
|
||||
| Empty vs unset | `null` = unset/cleared; `"[]"` = explicit empty list |
|
||||
| gRPC wire | Existing `string value` field carries the canonical JSON (additive) |
|
||||
|
||||
## Architecture
|
||||
|
||||
The key insight from the blast-radius survey: most of the runtime is already
|
||||
collection-capable. `ScriptParameters` already converts to typed `List<T>`/`T[]`; the OPC UA
|
||||
write path wraps values in a `Variant` (which supports arrays natively);
|
||||
`AttributeValueChanged` already carries `object?`. The real work concentrates in **(a)** the
|
||||
type model, **(b)** a canonical encode/decode codec, **(c)** the InstanceActor / script-accessor
|
||||
boundary, **(d)** validation, and **(e)** the UI/CLI editors.
|
||||
|
||||
### 1. Type model
|
||||
|
||||
- Add **one** enum member: `DataType.List`
|
||||
(`Commons/Types/Enums/DataType.cs`).
|
||||
- Add nullable `ElementDataType` (`DataType?`) to:
|
||||
- `Commons/Entities/Templates/TemplateAttribute.cs`
|
||||
- `Commons/Entities/Instances/InstanceAttributeOverride.cs`
|
||||
- `Commons/Types/Flattening/FlattenedConfiguration.cs` → `ResolvedAttribute`
|
||||
- **Invariant:** `ElementDataType` required when `DataType == List`, null otherwise; element
|
||||
type must be one of the 6 allowed scalars.
|
||||
|
||||
### 2. Canonical value codec — new `AttributeValueCodec`
|
||||
|
||||
One round-trippable codec used everywhere a value is **stored or transmitted**:
|
||||
|
||||
- `Encode(object? value)`:
|
||||
- scalar → invariant-culture string (**identical to today's behavior**; existing scalars
|
||||
unchanged)
|
||||
- non-string `IEnumerable` → JSON array, invariant culture
|
||||
(`["WO-1","WO-2"]`, `[1,2,3]`, `["2026-06-16T00:00:00Z"]`)
|
||||
- `Decode(string? value, DataType dataType, DataType? elementType)`:
|
||||
- `List` → typed `List<T>` (reuse the existing `ScriptParameters` element-conversion
|
||||
machinery where practical)
|
||||
- scalar → string (unchanged)
|
||||
- `null` value = unset/cleared; `"[]"` = explicit empty list.
|
||||
|
||||
`ValueFormatter.FormatDisplayValue` (comma-joined) remains **display-only** — diff text,
|
||||
log lines, KPI strings — and is *not* the persisted/wire form.
|
||||
|
||||
### 3. Persistence & migration
|
||||
|
||||
- One EF Core migration, **written idempotent** (per the open follow-up #70 lesson —
|
||||
guard inserts/alters so re-running against a partially-migrated DB is safe):
|
||||
- add `ElementDataType nvarchar(50) NULL` to `TemplateAttributes` and
|
||||
`InstanceAttributeOverrides`
|
||||
- widen `Value` / `OverrideValue` from `nvarchar(4000)` → `nvarchar(max)` (lists can
|
||||
exceed 4000)
|
||||
- Site **SQLite** static-attribute store: no schema change (already a string column) — it
|
||||
simply stores the canonical JSON.
|
||||
- EF config: `TemplateConfiguration.cs`, `InstanceConfiguration.cs` map the new column
|
||||
(`HasConversion<string>().HasMaxLength(50)` for `ElementDataType`).
|
||||
|
||||
### 4. Flatten / resolve
|
||||
|
||||
- `TemplateEngine/Flattening/FlatteningService.cs` carries `ElementDataType` into
|
||||
`ResolvedAttribute` next to `DataType`.
|
||||
- Inheritance / compose / override shape unchanged.
|
||||
- **Override replaces the whole list** (no per-element merge).
|
||||
- A derived template or instance override **cannot change `ElementDataType`** — it is fixed
|
||||
by the base attribute, enforced like the existing `LockedInDerived` path.
|
||||
|
||||
### 5. Site runtime (the core change)
|
||||
|
||||
- **`SiteRuntime/Scripts/ScopeAccessors.cs:56,73`** — `AttributeAccessor` set / `SetAsync`:
|
||||
replace `value?.ToString()` with `AttributeValueCodec.Encode(value)`. (Today a
|
||||
`List<string>` would `.ToString()` to `"System.Collections.Generic.List`1[...]"` — the
|
||||
central bug this fixes.) Scalars produce the same string as before.
|
||||
- **`SiteRuntime/Actors/InstanceActor.cs:116-121`** — on load, for `List` attributes
|
||||
`Decode` the JSON `Value` into a typed `List<T>` and store *that* in `_attributes`
|
||||
(scalars unchanged).
|
||||
- **`HandleSetStaticAttribute`** — for `List` attributes: decode the canonical string →
|
||||
typed list for `_attributes`, validate element types, persist the canonical JSON to
|
||||
SQLite, stream the canonical JSON.
|
||||
- **`HandleGetAttribute`** — unchanged; returns the live `_attributes` object, now a real
|
||||
`List<T>` for list attributes. Scripts read a first-class list.
|
||||
|
||||
### 6. Data Connection Layer (OPC UA read + write)
|
||||
|
||||
- **Read:** an OPC UA array node yields a CLR array → attribute value object. Coerce /
|
||||
validate element type against `ElementDataType`; on mismatch → **Bad quality** + log,
|
||||
never crash the actor.
|
||||
- **Write:** `DataConnectionLayer/Adapters/RealOpcUaClient.cs:619-628` already wraps values
|
||||
in `new Variant(value)`, which supports arrays natively; a `List<T>`/`T[]` writes as an
|
||||
OPC UA array. Minimal change beyond passing the typed list through.
|
||||
|
||||
### 7. Validation (pre-deploy semantic)
|
||||
|
||||
`TemplateEngine/Validation/SemanticValidator.cs` + `ValidationService.cs`:
|
||||
|
||||
- `ElementDataType` present and a valid scalar when `DataType == List`; absent otherwise.
|
||||
- Authored default `Value` (if present) must parse as a JSON array whose elements match
|
||||
`ElementDataType`.
|
||||
- List attributes **rejected as operands** in numeric alarm triggers (HiLo /
|
||||
RangeViolation) and binary triggers — extends the existing `NumericDataTypes` guard.
|
||||
|
||||
### 8. Streaming / DebugView
|
||||
|
||||
- `Communication/Actors/StreamRelayActor.cs:48` — encode via `AttributeValueCodec` (JSON for
|
||||
lists) instead of `FormatDisplayValue`. Additive: lists are a new type, so no existing wire
|
||||
consumer breaks; the proto `string value` field (`sitestream.proto:57`) is unchanged.
|
||||
- Central DebugView renders the canonical string; for lists it shows the JSON array.
|
||||
*Optional polish:* chip rendering of list elements.
|
||||
|
||||
### 9. Central UI
|
||||
|
||||
- **`CentralUI/Components/Pages/Design/TemplateEdit.razor`** — attribute form: when
|
||||
type = `List`, reveal an `ElementDataType` dropdown and a **list editor** (repeatable
|
||||
add/remove rows) bound to the JSON value; inline per-element validation.
|
||||
- **`CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`** — override panel: the
|
||||
same list editor for overriding a list attribute.
|
||||
|
||||
### 10. CLI
|
||||
|
||||
- **`CLI/Commands/TemplateCommands.cs`** (and the instance-override command): add
|
||||
`--element-type <Scalar>`; `--value` accepts a JSON array (`'["a","b"]'`). Optional
|
||||
ergonomic alternative: repeatable `--value`. Validate element type before submit.
|
||||
- `ManagementService/ManagementActor.cs` add/update attribute handlers: parse + validate
|
||||
`ElementDataType`, validate `Value` against it before persisting.
|
||||
|
||||
### 11. Transport (import/export)
|
||||
|
||||
- `Transport/Serialization/EntityDtos.cs` `TemplateAttributeDto` already carries the
|
||||
`DataType` enum + `string? Value`; add `ElementDataType`. Enum/value round-trip is
|
||||
otherwise automatic. `BundleImporter` carries the new field through.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Malformed JSON in a persisted `Value`: caught at deploy-time validation; if it ever
|
||||
reaches runtime decode, log + treat the attribute as Bad quality — never crash the
|
||||
Instance Actor (audit/best-effort principle).
|
||||
- OPC UA element-type mismatch on read: Bad quality + log (non-fatal).
|
||||
- Script writes a non-list to a list attribute (or vice versa): rejected at
|
||||
`HandleSetStaticAttribute` validation, surfaced to the script as an error.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Codec round-trip:** every element type; empty list; embedded commas/quotes/escaping;
|
||||
`DateTime`; culture-invariance.
|
||||
- **Flatten:** override replaces whole list; element-type lock enforced.
|
||||
- **Validation:** good/bad authored defaults; trigger-operand rejection; missing/extra
|
||||
`ElementDataType`.
|
||||
- **InstanceActor:** set/get typed list; SQLite persistence survives simulated failover.
|
||||
- **DCL:** OPC UA array read coercion; Bad quality on element mismatch; `Variant` array
|
||||
write.
|
||||
- **Streaming:** `StreamRelayActor` emits canonical JSON for a list attribute.
|
||||
- **CLI:** `--element-type` + JSON `--value` parse/validate.
|
||||
- **UI:** list-editor component behavior (if harness present).
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Nested objects / lists-of-records; heterogeneous lists.
|
||||
- `Binary` element lists.
|
||||
- Per-element overrides; element-level alarm triggers.
|
||||
|
||||
## Blast-radius reference (file:area)
|
||||
|
||||
| Area | Key locations | Change size |
|
||||
|---|---|---|
|
||||
| Enum + entities | `DataType.cs`, `TemplateAttribute.cs`, `InstanceAttributeOverride.cs`, `FlattenedConfiguration.cs` | small |
|
||||
| Codec (new) | `Commons/Types/AttributeValueCodec.cs` (new) | small–med |
|
||||
| EF + migration | `TemplateConfiguration.cs`, `InstanceConfiguration.cs`, new migration | small |
|
||||
| Flatten | `FlatteningService.cs:177` | small |
|
||||
| Runtime boundary | `ScopeAccessors.cs:56,73`, `InstanceActor.cs:116-121,246-315` | **med (core)** |
|
||||
| DCL | `RealOpcUaClient.cs` read coercion (write already array-capable) | small–med |
|
||||
| Validation | `SemanticValidator.cs:18-21,130-193`, `ValidationService.cs` | small–med |
|
||||
| Streaming | `StreamRelayActor.cs:48` | small |
|
||||
| UI | `TemplateEdit.razor`, `InstanceConfigure.razor` | **med (largest)** |
|
||||
| CLI + mgmt | `TemplateCommands.cs`, `ManagementActor.cs:1441-1461` | small–med |
|
||||
| Transport | `EntityDtos.cs:77-83`, `BundleImporter.cs:2302` | small |
|
||||
|
||||
**Note:** the Inbound API type system (`ParameterDefinition` with `Object`/`List`,
|
||||
`InboundApiSchema`) is genuinely separate from attribute `DataType` and is **not** modified;
|
||||
its `ScriptParameters.ConvertToList/ConvertToArray` conversion helpers are reusable by the
|
||||
codec.
|
||||
@@ -0,0 +1,626 @@
|
||||
# Structured Multi-Value (List) Attribute — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a first-class `DataType.List` attribute type (a homogeneous list of any scalar element type) that round-trips through authoring, flatten, site runtime, OPC UA read/write, streaming, UI, and CLI.
|
||||
|
||||
**Architecture:** One new `DataType.List` enum member + a nullable `ElementDataType` companion on the attribute entities and `ResolvedAttribute`. A single round-trippable `AttributeValueCodec` (JSON array, invariant culture) encodes/decodes list values everywhere they are stored or transmitted; scalars keep their current string behavior unchanged. The script-accessor `.ToString()` boundary and the InstanceActor in-memory store are the core runtime changes.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, Akka.NET 1.5, EF Core 10 (MS SQL + SQLite), gRPC, Blazor Server, System.CommandLine CLI.
|
||||
|
||||
**Design doc:** `docs/plans/2026-06-16-multivalue-attribute-design.md` (approved).
|
||||
**Branch:** `feature/multivalue-attribute` (off main; design committed `b238228`).
|
||||
|
||||
**Conventions for every task:**
|
||||
- TDD: write the failing test, see it fail, implement, see it pass, commit.
|
||||
- Targeted builds/tests only — build the affected project(s) and run the filtered test(s); a full-solution build runs once in the final task.
|
||||
- Build a project: `dotnet build src/<Project>/<Project>.csproj`
|
||||
- Run filtered tests: `dotnet test tests/<TestProject>/<TestProject>.csproj --filter <Name>`
|
||||
- Allowed element types (the 6 scalars): `String, Int32, Float, Double, Boolean, DateTime`. **Not** `Binary`, **not** nested `List`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-1: Type model — enum member + ElementDataType companion
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (foundation; everything else depends on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs` (the `ResolvedAttribute` record, ~line 57)
|
||||
|
||||
**Step 1 — Add the enum member.** Append `List` as the last member of `DataType` (append-only; do not reorder — the enum is persisted by name via `HasConversion<string>`, but appended-last is the safe convention):
|
||||
|
||||
```csharp
|
||||
public enum DataType
|
||||
{
|
||||
Boolean,
|
||||
Int32,
|
||||
Float,
|
||||
Double,
|
||||
String,
|
||||
DateTime,
|
||||
Binary,
|
||||
List
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Add `ElementDataType` to the two entities and the resolved record.**
|
||||
|
||||
`TemplateAttribute.cs` (after the `DataType DataType` property, ~line 26):
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// For <see cref="Enums.DataType.List"/> attributes: the scalar type of each
|
||||
/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar
|
||||
/// attributes. The element type is fixed by the base attribute and cannot be
|
||||
/// changed on a derived template or instance override.
|
||||
/// </summary>
|
||||
public DataType? ElementDataType { get; set; }
|
||||
```
|
||||
|
||||
`InstanceAttributeOverride.cs` (after `OverrideValue`, ~line 12): add the same `public DataType? ElementDataType { get; set; }` property (add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;`).
|
||||
|
||||
`FlattenedConfiguration.cs` — `ResolvedAttribute` record (after `DataType` at ~line 68). NOTE: `ResolvedAttribute.DataType` is a **string**; keep `ElementDataType` a nullable string here for symmetry with that record's existing style:
|
||||
```csharp
|
||||
/// <summary>For List attributes: the element scalar type name; null otherwise.</summary>
|
||||
public string? ElementDataType { get; init; }
|
||||
```
|
||||
|
||||
**Step 3 — Build the project.**
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj`
|
||||
Expected: 0 errors (purely additive).
|
||||
|
||||
**Step 4 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.Commons
|
||||
git commit -m "feat(commons): add DataType.List + ElementDataType companion for multi-value attributes"
|
||||
```
|
||||
|
||||
**Acceptance:** Commons compiles; `DataType.List` exists; both entities and `ResolvedAttribute` carry the nullable element-type field.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-2: AttributeValueCodec (round-trippable JSON encode/decode) + tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-3, MV-4
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs`
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs`
|
||||
|
||||
This codec is the single canonical encoder used for: persisted attribute `Value`, the gRPC wire value, and decode-on-load. `ValueFormatter` stays display-only and is untouched.
|
||||
|
||||
**Step 1 — Write failing tests.** Cover: scalar passthrough (string returned as-is; int/double/bool/DateTime → invariant string), list encode (`List<string>` → `["a","b"]`), embedded comma/quote escaping, empty list → `"[]"`, null → null, DateTime list round-trips ISO-8601, culture-invariance (set `CultureInfo.CurrentCulture` to `de-DE` and assert `Encode(1.5)` is `"1.5"`), decode round-trip for each element type, and decode of malformed JSON throws `FormatException` (caught by callers).
|
||||
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using Xunit;
|
||||
|
||||
public class AttributeValueCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Encode_StringList_ProducesJsonArray() =>
|
||||
Assert.Equal("[\"WO-1\",\"WO-2\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_String_ReturnedAsIs() =>
|
||||
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_Double_IsInvariant()
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("1.5", AttributeValueCodec.Encode(1.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_EmptyList_IsBracketPair() =>
|
||||
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
|
||||
|
||||
[Fact]
|
||||
public void Encode_StringWithComma_IsEscaped() =>
|
||||
Assert.Equal("[\"ACME, Inc.\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_Int32List()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(new List<int> { 1, 2, 3 });
|
||||
var back = (IList<int>)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!;
|
||||
Assert.Equal(new[] { 1, 2, 3 }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_Scalar_ReturnsString() =>
|
||||
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
|
||||
|
||||
[Fact]
|
||||
public void Decode_MalformedJson_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect FAIL** (`AttributeValueCodec` not defined).
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests`
|
||||
|
||||
**Step 3 — Implement.** Use `System.Text.Json` with invariant formatting. For decode, reuse the element-conversion idea from `ScriptParameters.ConvertScalar` (`src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:161`) — keep the codec self-contained (small private scalar-parser switch on `DataType`).
|
||||
|
||||
```csharp
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, round-trippable codec for attribute values. Scalars encode to an
|
||||
/// invariant-culture string (identical to the historical representation); List
|
||||
/// attributes encode to a JSON array. Used wherever a value is stored or
|
||||
/// transmitted (DB Value column, site SQLite, gRPC wire). <see cref="ValueFormatter"/>
|
||||
/// remains a separate, display-only (comma-joined) formatter.
|
||||
/// </summary>
|
||||
public static class AttributeValueCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||
|
||||
/// <summary>Encodes a value to its canonical string form.</summary>
|
||||
public static string? Encode(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: return null;
|
||||
case string s: return s; // already canonical
|
||||
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
|
||||
case IEnumerable e:
|
||||
var items = e.Cast<object?>()
|
||||
.Select(x => x is IFormattable xf
|
||||
? xf.ToString(null, CultureInfo.InvariantCulture)
|
||||
: x?.ToString());
|
||||
return JsonSerializer.Serialize(items, JsonOpts);
|
||||
default: return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a canonical string. For <see cref="DataType.List"/> returns a typed
|
||||
/// <c>List<T></c>; for scalars returns the string unchanged. Throws
|
||||
/// <see cref="FormatException"/> on malformed list JSON or an un-parseable element.
|
||||
/// </summary>
|
||||
public static object? Decode(string? value, DataType dataType, DataType? elementType)
|
||||
{
|
||||
if (dataType != DataType.List) return value; // scalar: unchanged
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (elementType is null)
|
||||
throw new FormatException("List attribute requires an element type.");
|
||||
|
||||
string?[] raw;
|
||||
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
|
||||
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
|
||||
|
||||
var clrType = ElementClrType(elementType.Value);
|
||||
var listType = typeof(List<>).MakeGenericType(clrType);
|
||||
var result = (IList)Activator.CreateInstance(listType)!;
|
||||
foreach (var item in raw)
|
||||
result.Add(ParseScalar(item, elementType.Value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Type ElementClrType(DataType t) => t switch
|
||||
{
|
||||
DataType.String => typeof(string),
|
||||
DataType.Int32 => typeof(int),
|
||||
DataType.Float => typeof(float),
|
||||
DataType.Double => typeof(double),
|
||||
DataType.Boolean => typeof(bool),
|
||||
DataType.DateTime => typeof(DateTime),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
|
||||
private static object? ParseScalar(string? s, DataType t)
|
||||
{
|
||||
if (s is null) throw new FormatException("List elements may not be null.");
|
||||
var c = CultureInfo.InvariantCulture;
|
||||
try
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
DataType.String => s,
|
||||
DataType.Int32 => int.Parse(s, c),
|
||||
DataType.Float => float.Parse(s, c),
|
||||
DataType.Double => double.Parse(s, c),
|
||||
DataType.Boolean => bool.Parse(s),
|
||||
DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||
{
|
||||
throw new FormatException($"List element '{s}' is not a valid {t}.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the type may be a List element scalar.</summary>
|
||||
public static bool IsValidElementType(DataType t) =>
|
||||
t is DataType.String or DataType.Int32 or DataType.Float
|
||||
or DataType.Double or DataType.Boolean or DataType.DateTime;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — Run tests, expect PASS.**
|
||||
|
||||
**Step 5 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
|
||||
git commit -m "feat(commons): AttributeValueCodec for canonical list value encode/decode"
|
||||
```
|
||||
|
||||
**Acceptance:** all codec tests pass; scalars unchanged; lists round-trip; malformed JSON throws `FormatException`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-3: EF mapping + idempotent migration
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-2, MV-4
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:111-122`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs:102-103`
|
||||
- Create: a new migration under `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/`
|
||||
|
||||
**Step 1 — EF config.** In `TemplateConfiguration.Configure`: widen `Value` and map the new column:
|
||||
```csharp
|
||||
builder.Property(a => a.Value); // remove .HasMaxLength(4000) → defaults to nvarchar(max)
|
||||
|
||||
builder.Property(a => a.ElementDataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
```
|
||||
In `InstanceConfiguration` (the `InstanceAttributeOverride` config): drop `.HasMaxLength(4000)` from `OverrideValue`, and add the same `ElementDataType` mapping.
|
||||
|
||||
**Step 2 — Generate the migration.**
|
||||
```bash
|
||||
dotnet ef migrations add AddListAttributeElementType \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
|
||||
**Step 3 — Make `Up`/`Down` idempotent** (per open follow-up #70 — re-running against a partially-migrated prod DB must not throw). Wrap the generated `AddColumn`/`AlterColumn` calls with existence guards via `migrationBuilder.Sql(...)`:
|
||||
```csharp
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes'))
|
||||
ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides'))
|
||||
ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;");
|
||||
```
|
||||
Replace the auto-generated `AddColumn`/`AlterColumn` statements with the guarded SQL above (keep the `.Designer.cs` snapshot the tool generated — only the `Up`/`Down` body becomes guarded SQL). Provide a `Down` that drops the columns if present and restores `nvarchar(4000)`.
|
||||
|
||||
**Step 4 — Verify no model drift.**
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
|
||||
Then confirm there are no pending model changes:
|
||||
```bash
|
||||
dotnet ef migrations has-pending-model-changes \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
Expected: "No changes have been made to the model since the last migration" (or exit 0).
|
||||
|
||||
**Step 5 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase
|
||||
git commit -m "feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent)"
|
||||
```
|
||||
|
||||
**Acceptance:** project builds; no pending model changes; migration `Up`/`Down` are guarded/idempotent.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-4: Flatten carries ElementDataType into ResolvedAttribute
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-2, MV-3
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:177` (and any other site that constructs a `ResolvedAttribute`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs`
|
||||
|
||||
**Step 1 — Failing test:** flatten a template whose attribute is `DataType.List` / `ElementDataType=String` and assert the `ResolvedAttribute` has `DataType=="List"` and `ElementDataType=="String"`; assert an instance override of that attribute keeps `ElementDataType` (override replaces value, not element type).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** wherever `ResolvedAttribute` is built (e.g. `DataType = attr.DataType.ToString()`), also set `ElementDataType = attr.ElementDataType?.ToString()`. Grep for `new ResolvedAttribute` and `DataType = ` in `FlatteningService.cs` to find every construction site and the override-merge path.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(template): carry ElementDataType through flatten/override`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-5: Semantic validation for List attributes
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-6, MV-8, MV-9 (different files)
|
||||
**Blocked by:** MV-1, MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18-21,130-193`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs`
|
||||
|
||||
**Rules to enforce (write a failing test for each first):**
|
||||
1. `DataType.List` requires a non-null `ElementDataType` that is a valid element scalar (`AttributeValueCodec.IsValidElementType`); a scalar attribute must have `ElementDataType == null`. Violation → validation Error.
|
||||
2. An authored default `Value` on a List attribute, if present, must `AttributeValueCodec.Decode` without throwing (catch `FormatException` → Error with the element type and message).
|
||||
3. A List attribute used as an operand in a numeric trigger (HiLo / RangeViolation) or a binary trigger → Error. Extend the existing `NumericDataTypes` operand check (~line 130-193): a `List` operand is never numeric and never a valid binary operand.
|
||||
|
||||
**Steps:** failing tests → run (FAIL) → implement the three checks → run (PASS) → commit `feat(validation): semantic checks for List attributes (element type, default value, trigger operands)`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-6: Script-accessor encode boundary
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-8, MV-9
|
||||
**Blocked by:** MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:56,73`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/...` (accessor or runtime-context test; create if absent)
|
||||
|
||||
**Problem:** `AttributeAccessor` set/`SetAsync` currently do `value?.ToString()`, which turns a `List<string>` into `"System.Collections.Generic.List`1[System.String]"`.
|
||||
|
||||
**Step 1 — Failing test:** a fake/seam `ScriptRuntimeContext.SetAttribute(name, encoded)` capturing the encoded string; assert setting a `List<string>{"a","b"}` sends `["a","b"]` and setting a scalar `"x"` still sends `"x"`.
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** replace both `.ToString()` sites with the codec:
|
||||
```csharp
|
||||
set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty)
|
||||
.GetAwaiter().GetResult();
|
||||
// and in SetAsync:
|
||||
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
|
||||
```
|
||||
Add `using ZB.MOM.WW.ScadaBridge.Commons.Types;`.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString())`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-7: InstanceActor decode (load + static set + override merge)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** MV-1, MV-2, MV-4, MV-6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:114-122` (load), `:307-320` (`HandleSetStaticAttributeCore`), and the static-override merge on load (grep `GetStaticOverridesAsync` usage)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs`
|
||||
|
||||
**Goal:** `_attributes` holds a typed `List<T>` for List attributes so `HandleGetAttribute` returns a real list to scripts, and the canonical JSON string is what gets persisted + streamed.
|
||||
|
||||
**Step 1 — Failing tests** (use the existing InstanceActor test harness / TestKit):
|
||||
- Load a flattened config with a List attribute default `["a","b"]` → `GetAttributeRequest` returns an `IEnumerable` of `{"a","b"}` (not the raw string).
|
||||
- `SetStaticAttributeCommand` with `Value = "[\"x\",\"y\"]"` on a List attribute → subsequent get returns `{"x","y"}`; assert `SetStaticOverrideAsync` was called with the JSON string `["x","y"]` (persisted form is canonical JSON).
|
||||
- A persisted SQLite override `["p","q"]` applied on load decodes to a list.
|
||||
- Malformed stored value → attribute loads with Bad quality, actor does not throw (wrap `Decode` in try/catch → log + set quality Bad).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement.**
|
||||
- Add a small private helper on the actor:
|
||||
```csharp
|
||||
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
|
||||
{
|
||||
var dataType = Enum.Parse<DataType>(attr.DataType, ignoreCase: true);
|
||||
var elementType = string.IsNullOrEmpty(attr.ElementDataType)
|
||||
? (DataType?)null
|
||||
: Enum.Parse<DataType>(attr.ElementDataType, ignoreCase: true);
|
||||
try { return AttributeValueCodec.Decode(raw, dataType, elementType); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Attribute '{Attr}' on '{Instance}' has an undecodable value; marking Bad quality",
|
||||
attr.CanonicalName, _instanceUniqueName);
|
||||
return null; // caller sets quality Bad
|
||||
}
|
||||
}
|
||||
```
|
||||
- Load loop (`:116-121`): `_attributes[attr.CanonicalName] = DecodeAttributeValue(attr, attr.Value);` and set quality `Bad` when a List value failed to decode (non-null raw but null result).
|
||||
- Apply the same decode when merging persisted static overrides (the `GetStaticOverridesAsync` merge).
|
||||
- `HandleSetStaticAttributeCore` (`:309`): look up the `ResolvedAttribute` for `command.AttributeName`; store `DecodeAttributeValue(resolved, command.Value)` in `_attributes` (so reads are typed) while continuing to **persist and publish `command.Value`** (the canonical JSON string) unchanged. The published `AttributeValueChanged` keeps carrying the canonical string — `StreamRelayActor` (MV-9) handles encoding uniformly.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(siteruntime): decode List attributes to typed lists in InstanceActor (load/set/override)`.
|
||||
|
||||
**Acceptance:** scripts read List attributes as `List<T>`; persisted + streamed form is canonical JSON; undecodable values degrade to Bad quality without crashing the actor.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-8: DCL OPC UA array read coercion
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-5, MV-6, MV-9
|
||||
**Blocked by:** MV-1, MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (read/subscription value handling) — and the InstanceActor handler that ingests a DCL value for a data-sourced attribute (grep for where incoming tag values update `_attributes` / publish `AttributeValueChanged`).
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/...`
|
||||
|
||||
**Goal:** when a data-sourced attribute is declared `DataType.List`, an incoming OPC UA array value becomes a typed list with each element coerced to `ElementDataType`; element-type mismatch → Bad quality + log (non-fatal). The **write** path needs no change (`RealOpcUaClient.WriteValueAsync` already wraps in `Variant`, which serializes a `List<T>`/`T[]` as an OPC UA array — add a test asserting an array value writes without exception).
|
||||
|
||||
**Steps:** failing tests (array read → typed list of correct element type; mismatched element → Bad quality; array write via Variant succeeds) → run (FAIL) → implement coercion using `AttributeValueCodec`/`Convert` per element → run (PASS) → commit `feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-9: StreamRelayActor canonical-JSON encode
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** MV-5, MV-6, MV-8
|
||||
**Blocked by:** MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:48`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/...`
|
||||
|
||||
**Step 1 — Failing test:** relay an `AttributeValueChanged` whose `Value` is a `List<string>{"a","b"}` → assert the produced `AttributeValueUpdate.Value == "[\"a\",\"b\"]"`; relay a scalar string `"x"` → assert `Value == "x"` (unchanged).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** replace `Value = ValueFormatter.FormatDisplayValue(msg.Value),` with `Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,` (add the `using`). This is additive — List is a new type, no existing wire consumer relies on comma-joined lists; the proto `string value` field is unchanged.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(comm): stream List attribute values as canonical JSON`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-10: ManagementActor add/update attribute handlers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-11 not (blocks it); MV-12/13 share contract
|
||||
**Blocked by:** MV-1, MV-2, MV-5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:~1441-1461` (add/update attribute), `:526` (the `DataType.ToString()` serialization for read-back)
|
||||
- Modify: the management command/message contract that carries an attribute (add `ElementDataType` — additive field; grep for the `AddAttribute`/`UpdateAttribute` command records in `Commons/Messages/...`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/...`
|
||||
|
||||
**Goal:** the add/update-attribute path accepts an optional element-type, validates `(DataType, ElementDataType, Value)` via the MV-5 rules + `AttributeValueCodec`, and persists both columns. Read-back includes `ElementDataType`.
|
||||
|
||||
**Steps:** failing tests (add a List attribute with element type String + JSON default → persisted with both columns; add List without element type → rejected; add List with bad default → rejected) → run (FAIL) → implement parse/validate/persist → run (PASS) → commit `feat(mgmt): accept + validate ElementDataType on attribute add/update`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-11: CLI element-type + JSON value
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** MV-12, MV-13
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:140-199` (attribute add/update) and the instance-override command if present
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document `--element-type` + JSON `--value`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/...` (option parsing)
|
||||
|
||||
**Goal:** add `--element-type <Scalar>`; `--value` accepts a JSON array for List attributes. Validate element type client-side before sending; surface server validation errors.
|
||||
|
||||
**Steps:** failing test (parse `--data-type List --element-type String --value '["a","b"]'` → request carries List + String + JSON) → run (FAIL) → implement option + plumb to the management call → run (PASS) → commit `feat(cli): --element-type and JSON --value for List attributes`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-12: Transport DTO + importer field
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-4, MV-9, MV-11, MV-13
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/...` or `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:77-83` (`TemplateAttributeDto`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:~2300-2306`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...` (round-trip export→import of a List attribute)
|
||||
|
||||
**Goal:** the export/import DTO carries `ElementDataType`; a List attribute survives an export→import round-trip. Old bundles without the field import as scalars (null element type) — assert backward-compat.
|
||||
|
||||
**Steps:** failing round-trip test → run (FAIL) → add `ElementDataType` to the DTO + importer mapping → run (PASS) → commit `feat(transport): round-trip ElementDataType for List attributes`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-13: Central UI — TemplateEdit list editor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-11, MV-12, MV-14
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:82,484-491,556-566`
|
||||
- (Optional) Create: a small `AttributeListEditor.razor` shared component under `CentralUI/Components/Shared/`
|
||||
- Test/verify: Playwright fixture if the harness covers TemplateEdit; otherwise manual verification noted in the task.
|
||||
|
||||
Use the **frontend-design** skill for the editor UI (clean corporate Bootstrap, no third-party component frameworks — per CLAUDE.md).
|
||||
|
||||
**Goal:** when the attribute `DataType` dropdown = `List`, reveal an `ElementDataType` dropdown (the 6 scalars) and a repeatable add/remove row editor bound to the JSON value. Inline per-element validation by element type. The dropdown already enumerates `Enum.GetValues<DataType>()`, so `List` appears automatically — gate the element-type + list editor on `_attrDataType == DataType.List`.
|
||||
|
||||
**Steps:** implement the conditional editor; bind to `AttributeValueCodec.Encode` of the rows for submit; decode existing JSON into rows on edit; build `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/...`; verify (Playwright or manual) → commit `feat(ui): List attribute editor in TemplateEdit`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-14: Central UI — InstanceConfigure override list editor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-13
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`
|
||||
- Reuse: the `AttributeListEditor.razor` component from MV-13 if created.
|
||||
|
||||
**Goal:** overriding a List attribute on an instance uses the same list editor (whole-list replacement; element type shown read-only — fixed by base). Clearing the override removes it.
|
||||
|
||||
**Steps:** implement using the shared editor; element-type dropdown is read-only here; build CentralUI; verify → commit `feat(ui): List attribute override editor in InstanceConfigure`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-15: Integration verification + docs/README sync
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** all (MV-1 … MV-14)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` and/or `docs/requirements/Component-*.md` if a DataType/attribute capability note needs updating (Template Engine / Commons).
|
||||
- Modify: `docs/plans/2026-06-16-multivalue-attribute-design.md` — mark Status complete.
|
||||
|
||||
**Steps:**
|
||||
1. Full-solution build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect 0 errors.
|
||||
2. Run the feature's targeted test classes across the touched test projects (codec, flatten, validation, InstanceActor, DCL, comm, transport, CLI).
|
||||
3. End-to-end smoke (optional, if exercising the cluster): `bash docker/deploy.sh`, then via CLI create a template with a List attribute (`--data-type List --element-type String --value '["WO-1","WO-2"]'`), deploy an instance, and confirm the value flows to the DebugView as `["WO-1","WO-2"]`.
|
||||
4. Update docs; sync the design-decision note if the high-level requirements track the DataType set.
|
||||
5. Commit: `docs: mark multi-value attribute feature complete; sync README/component notes`.
|
||||
|
||||
**Acceptance:** full build green; all targeted tests pass; (if run) the value round-trips end-to-end through the cluster.
|
||||
|
||||
---
|
||||
|
||||
## Parallelization summary
|
||||
|
||||
- **Wave 1:** MV-1 (foundation, solo).
|
||||
- **Wave 2 (after MV-1):** MV-2, MV-3, MV-4, MV-12 in parallel (disjoint files).
|
||||
- **Wave 3 (after MV-2):** MV-5, MV-6, MV-8, MV-9 in parallel; MV-7 after MV-6.
|
||||
- **Wave 4 (after MV-5):** MV-10; then MV-11, MV-13, MV-14 in parallel after MV-10.
|
||||
- **Wave 5:** MV-15 (final, solo).
|
||||
|
||||
## Risk notes
|
||||
|
||||
- **MV-3** (migration) and **MV-7** (actor model) are the high-risk tasks — full review chain.
|
||||
- The migration must be idempotent (open follow-up #70 is the cautionary precedent).
|
||||
- The gRPC change (MV-9) is additive — `List` is a brand-new type, so no existing wire consumer breaks; the proto field is unchanged.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-multivalue-attribute.md",
|
||||
"designDoc": "docs/plans/2026-06-16-multivalue-attribute-design.md",
|
||||
"branch": "feature/multivalue-attribute",
|
||||
"status": "complete",
|
||||
"tasks": [
|
||||
{"id": 77, "ref": "MV-1", "subject": "Type model — DataType.List + ElementDataType companion", "class": "standard", "status": "completed", "commits": ["70fa0e7"]},
|
||||
{"id": 78, "ref": "MV-2", "subject": "AttributeValueCodec + tests", "class": "standard", "status": "completed", "commits": ["8bd8079", "492b41f"]},
|
||||
{"id": 79, "ref": "MV-3", "subject": "EF mapping + idempotent migration", "class": "high-risk", "status": "completed", "commits": ["4a4b3d6"]},
|
||||
{"id": 80, "ref": "MV-4", "subject": "Flatten carries ElementDataType", "class": "small", "status": "completed", "commits": ["02aff24", "492b41f"]},
|
||||
{"id": 81, "ref": "MV-5", "subject": "Semantic validation for List attributes", "class": "standard", "status": "completed", "commits": ["872ce2b", "6ef6bab"]},
|
||||
{"id": 82, "ref": "MV-6", "subject": "Script-accessor encode boundary", "class": "small", "status": "completed", "commits": ["a1d464b"]},
|
||||
{"id": 83, "ref": "MV-7", "subject": "InstanceActor decode (load + set + override)", "class": "high-risk", "status": "completed", "commits": ["7f97780", "ad6bfc8"]},
|
||||
{"id": 84, "ref": "MV-8", "subject": "DCL OPC UA array read coercion", "class": "standard", "status": "completed", "commits": ["4765706", "96e817a"]},
|
||||
{"id": 85, "ref": "MV-9", "subject": "StreamRelayActor canonical-JSON encode", "class": "small", "status": "completed", "commits": ["ba414cb"]},
|
||||
{"id": 86, "ref": "MV-10", "subject": "ManagementActor add/update attribute handlers", "class": "standard", "status": "completed", "commits": ["1525670", "0164f8a"]},
|
||||
{"id": 87, "ref": "MV-11", "subject": "CLI --element-type + JSON --value", "class": "standard", "status": "completed", "commits": ["85db457", "100540b"]},
|
||||
{"id": 88, "ref": "MV-12", "subject": "Transport DTO + importer field", "class": "small", "status": "completed", "commits": ["e7e34b2", "492b41f"]},
|
||||
{"id": 89, "ref": "MV-13", "subject": "Central UI — TemplateEdit list editor", "class": "standard", "status": "completed", "commits": ["ba7331e", "100540b"]},
|
||||
{"id": 90, "ref": "MV-14", "subject": "Central UI — InstanceConfigure override list editor", "class": "standard", "status": "completed", "commits": ["ae2e1ef", "ca9ee5e"]},
|
||||
{"id": 91, "ref": "MV-15", "subject": "Integration verification + docs/README sync", "class": "standard", "status": "completed"}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
}
|
||||
@@ -26,7 +26,7 @@ Referenced by all component libraries and the Host.
|
||||
|
||||
Commons must define shared primitive and utility types used across multiple components, including but not limited to:
|
||||
|
||||
- **`DataType` enum**: Enumerates the data types supported by the system (e.g., Boolean, Int32, Float, Double, String, DateTime, Binary).
|
||||
- **`DataType` enum**: Enumerates the data types supported by the system: Boolean, Int32, Float, Double, String, DateTime, Binary, and **List** (a homogeneous multi-value attribute). A `List` attribute carries a companion `ElementDataType` (one of the scalar types — String, Int32, Float, Double, Boolean, DateTime; not Binary, not nested List) on `TemplateAttribute` / `InstanceAttributeOverride` and the flattened `ResolvedAttribute`. List values are stored and transmitted as a canonical JSON array via the `AttributeValueCodec` helper (scalars keep their historical invariant-culture string form); the `ElementDataType` is fixed by the defining level and cannot be changed on a derived template or instance override.
|
||||
- **`RetryPolicy`**: A record or immutable class describing retry behavior (max retries, fixed delay between retries).
|
||||
- **`Result<T>`**: A discriminated result type that represents either a success value or an error, enabling consistent error handling across component boundaries without exceptions.
|
||||
- **`InstanceState` enum**: Enabled, Disabled.
|
||||
|
||||
@@ -138,9 +138,10 @@ public static class TemplateCommands
|
||||
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var valueOption = new Option<string?>("--value") { Description = "Default value" };
|
||||
var valueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var descOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var elementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
@@ -151,28 +152,39 @@ public static class TemplateCommands
|
||||
addCmd.Add(valueOption);
|
||||
addCmd.Add(descOption);
|
||||
addCmd.Add(sourceOption);
|
||||
addCmd.Add(elementTypeOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var dataType = result.GetValue(dataTypeOption)!;
|
||||
var elementType = result.GetValue(elementTypeOption);
|
||||
if (!TryValidateElementType(dataType, elementType, out var error))
|
||||
{
|
||||
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AddTemplateAttributeCommand(
|
||||
BuildAddAttributeCommand(
|
||||
result.GetValue(templateIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(dataTypeOption)!,
|
||||
dataType,
|
||||
result.GetValue(valueOption),
|
||||
result.GetValue(descOption),
|
||||
result.GetValue(sourceOption),
|
||||
result.GetValue(lockedOption)));
|
||||
result.GetValue(lockedOption),
|
||||
elementType));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
|
||||
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
|
||||
var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
|
||||
var updateValueOption = new Option<string?>("--value") { Description = "Default value" };
|
||||
var updateValueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
|
||||
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
|
||||
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
|
||||
var updateElementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
@@ -183,19 +195,29 @@ public static class TemplateCommands
|
||||
updateCmd.Add(updateValueOption);
|
||||
updateCmd.Add(updateDescOption);
|
||||
updateCmd.Add(updateSourceOption);
|
||||
updateCmd.Add(updateElementTypeOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var dataType = result.GetValue(updateDataTypeOption)!;
|
||||
var elementType = result.GetValue(updateElementTypeOption);
|
||||
if (!TryValidateElementType(dataType, elementType, out var error))
|
||||
{
|
||||
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateTemplateAttributeCommand(
|
||||
BuildUpdateAttributeCommand(
|
||||
result.GetValue(updateIdOption),
|
||||
result.GetValue(updateNameOption)!,
|
||||
result.GetValue(updateDataTypeOption)!,
|
||||
dataType,
|
||||
result.GetValue(updateValueOption),
|
||||
result.GetValue(updateDescOption),
|
||||
result.GetValue(updateSourceOption),
|
||||
result.GetValue(updateLockedOption)));
|
||||
result.GetValue(updateLockedOption),
|
||||
elementType));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
@@ -213,6 +235,82 @@ public static class TemplateCommands
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>Shared description for the <c>--element-type</c> option on attribute add/update.</summary>
|
||||
internal const string ElementTypeOptionDescription =
|
||||
"Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List.";
|
||||
|
||||
/// <summary>The element scalar types permitted for a List attribute (matches the Management API).</summary>
|
||||
private static readonly string[] ValidElementScalars =
|
||||
{ "String", "Int32", "Float", "Double", "Boolean", "DateTime" };
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>--data-type</c> / <c>--element-type</c> combination client-side so
|
||||
/// the CLI fails fast with a clear message before contacting the Management API (the
|
||||
/// server validates independently). A List attribute requires a valid element scalar;
|
||||
/// a non-List attribute must not carry an element type. Comparison is case-insensitive.
|
||||
/// </summary>
|
||||
/// <param name="dataType">The raw <c>--data-type</c> value.</param>
|
||||
/// <param name="elementType">The raw <c>--element-type</c> value, or null if absent.</param>
|
||||
/// <param name="error">A descriptive error message when validation fails; otherwise null.</param>
|
||||
/// <returns><c>true</c> when the combination is valid; otherwise <c>false</c>.</returns>
|
||||
internal static bool TryValidateElementType(string dataType, string? elementType, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var isList = string.Equals(dataType, "List", StringComparison.OrdinalIgnoreCase);
|
||||
var hasElementType = !string.IsNullOrWhiteSpace(elementType);
|
||||
|
||||
if (isList)
|
||||
{
|
||||
if (!hasElementType)
|
||||
{
|
||||
error = "--element-type is required when --data-type is List "
|
||||
+ "(one of: String, Int32, Float, Double, Boolean, DateTime).";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
|
||||
+ string.Join(", ", ValidElementScalars) + ".";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasElementType)
|
||||
{
|
||||
error = "--element-type is only valid when --data-type is List.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="AddTemplateAttributeCommand"/> payload sent to the Management API.
|
||||
/// The raw <paramref name="value"/> string is forwarded unchanged — for a List attribute it
|
||||
/// is a JSON array, which the API/codec parses; the CLI does not reshape it.
|
||||
/// </summary>
|
||||
internal static AddTemplateAttributeCommand BuildAddAttributeCommand(
|
||||
int templateId, string name, string dataType, string? value,
|
||||
string? description, string? dataSource, bool isLocked, string? elementType)
|
||||
=> new(templateId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType));
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="UpdateTemplateAttributeCommand"/> payload sent to the Management API.
|
||||
/// The raw <paramref name="value"/> string is forwarded unchanged — for a List attribute it
|
||||
/// is a JSON array, which the API/codec parses; the CLI does not reshape it.
|
||||
/// </summary>
|
||||
internal static UpdateTemplateAttributeCommand BuildUpdateAttributeCommand(
|
||||
int attributeId, string name, string dataType, string? value,
|
||||
string? description, string? dataSource, bool isLocked, string? elementType)
|
||||
=> new(attributeId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType));
|
||||
|
||||
/// <summary>Trims a non-empty element type; an empty/whitespace value becomes null (no element type).</summary>
|
||||
private static string? NormalizeElementType(string? elementType)
|
||||
=> string.IsNullOrWhiteSpace(elementType) ? null : elementType.Trim();
|
||||
|
||||
private static Command BuildAlarm(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm") { Description = "Manage template alarms" };
|
||||
|
||||
@@ -164,45 +164,62 @@ scadabridge --url <url> template validate --id <int>
|
||||
Add an attribute to a template.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template attribute add --template-id <int> --name <string> --data-type <string> [--default-value <string>] [--tag-path <string>]
|
||||
scadabridge --url <url> template attribute add --template-id <int> --name <string> --data-type <string> [--value <string>] [--element-type <string>] [--description <string>] [--data-source <string>] [--locked]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--template-id` | yes | Template ID |
|
||||
| `--name` | yes | Attribute name |
|
||||
| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) |
|
||||
| `--default-value` | no | Default value |
|
||||
| `--tag-path` | no | Data connection tag path |
|
||||
| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
|
||||
| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`); the raw string is forwarded to the API, which parses it |
|
||||
| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise |
|
||||
| `--description` | no | Description |
|
||||
| `--data-source` | no | Data source reference |
|
||||
| `--locked` | no | Lock the attribute in derived templates |
|
||||
|
||||
**List example** — add a multi-value String attribute with two default elements:
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template attribute add --template-id 7 --name WorkOrders \
|
||||
--data-type List --element-type String --value '["WO-1","WO-2"]'
|
||||
```
|
||||
|
||||
The CLI validates the data-type / element-type combination locally before calling the
|
||||
API: `--data-type List` requires a valid `--element-type`, and `--element-type` may only
|
||||
be supplied when `--data-type` is `List`. The Management API re-validates server-side.
|
||||
|
||||
#### `template attribute update`
|
||||
|
||||
Update an attribute on a template.
|
||||
Update an attribute on a template. An update **replaces** the whole entity — every
|
||||
required field below must be supplied with its post-update value, even if unchanged.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template attribute update --template-id <int> --name <string> [--data-type <string>] [--default-value <string>] [--tag-path <string>]
|
||||
scadabridge --url <url> template attribute update --id <int> --name <string> --data-type <string> [--value <string>] [--element-type <string>] [--description <string>] [--data-source <string>] [--locked]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--template-id` | yes | Template ID |
|
||||
| `--name` | yes | Attribute name to update |
|
||||
| `--data-type` | no | Updated data type |
|
||||
| `--default-value` | no | Updated default value |
|
||||
| `--tag-path` | no | Updated tag path |
|
||||
| `--id` | yes | Attribute ID |
|
||||
| `--name` | yes | Attribute name |
|
||||
| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
|
||||
| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) |
|
||||
| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise |
|
||||
| `--description` | no | Description |
|
||||
| `--data-source` | no | Data source reference |
|
||||
| `--locked` | no | Lock the attribute in derived templates |
|
||||
|
||||
#### `template attribute delete`
|
||||
|
||||
Remove an attribute from a template.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> template attribute delete --template-id <int> --name <string>
|
||||
scadabridge --url <url> template attribute delete --id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--template-id` | yes | Template ID |
|
||||
| `--name` | yes | Attribute name to delete |
|
||||
| `--id` | yes | Attribute ID |
|
||||
|
||||
#### `template alarm add`
|
||||
|
||||
|
||||
+183
-10
@@ -5,6 +5,7 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@@ -184,26 +185,63 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<table class="table table-sm table-bordered mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Type</th>
|
||||
<th>Template Value</th>
|
||||
<th style="width: 280px;">Override Value</th>
|
||||
<th style="width: 320px;">Override Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _overrideAttrs)
|
||||
{
|
||||
<tr>
|
||||
<tr data-test="override-row-@attr.Name">
|
||||
<td class="small">@attr.Name</td>
|
||||
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">@attr.DataType</span>
|
||||
@if (attr.DataType == DataType.List)
|
||||
{
|
||||
@* Element type is fixed by the base attribute — shown
|
||||
read-only here (the List editor renders it hidden via
|
||||
ShowElementType="false"). *@
|
||||
<span class="badge bg-light text-dark border ms-1"
|
||||
data-test="override-element-type">
|
||||
of @(attr.ElementDataType ?? DataType.String)
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">@(attr.Value ?? "—")</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
value="@GetOverrideValue(attr.Name)"
|
||||
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
|
||||
@if (attr.DataType == DataType.List)
|
||||
{
|
||||
@* Whole-list replacement: the shared editor renders the
|
||||
element-type select hidden (fixed by the base) plus the
|
||||
repeatable rows. Clearing removes the override row. *@
|
||||
<AttributeListEditor ElementDataType="@(attr.ElementDataType ?? DataType.String)"
|
||||
Rows="@GetListRows(attr.Name)"
|
||||
RowsChanged="@(r => OnListRowsChanged(attr.Name, r))"
|
||||
ShowElementType="false" />
|
||||
@if (_overrideErrors.TryGetValue(attr.Name, out var listErr))
|
||||
{
|
||||
<div class="alert alert-danger small mt-2 mb-0"
|
||||
data-test="override-list-error">@listErr</div>
|
||||
}
|
||||
@if (HasOverrideRow(attr.Name))
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm mt-2"
|
||||
data-test="override-clear-btn"
|
||||
@onclick="() => ClearListOverride(attr.Name)"
|
||||
disabled="@_saving">Clear Override</button>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
value="@GetOverrideValue(attr.Name)"
|
||||
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -507,6 +545,17 @@
|
||||
// Overrides
|
||||
private List<TemplateAttribute> _overrideAttrs = new();
|
||||
private Dictionary<string, string?> _overrideValues = new();
|
||||
// MV-14: existing override rows keyed by attribute name — tracks which
|
||||
// attributes already have a persisted override (so List rows know whether a
|
||||
// Clear is available) and carries the row Id for repository-direct delete.
|
||||
private Dictionary<string, InstanceAttributeOverride> _existingOverrides = new();
|
||||
// MV-14: per-List working rows (whole-list replacement), keyed by attribute
|
||||
// name. Seeded on load from the effective value; encoded to canonical JSON on
|
||||
// save. Element type is fixed by the base attribute.
|
||||
private Dictionary<string, List<string>> _listRows = new();
|
||||
// MV-14: per-attribute validation errors surfaced inline (e.g. an
|
||||
// un-parseable List element caught on the pre-submit round-trip).
|
||||
private Dictionary<string, string> _overrideErrors = new();
|
||||
|
||||
// Alarm overrides — read-only state pulled from the repo. The edit modal
|
||||
// is the only mutation path (one alarm at a time).
|
||||
@@ -595,7 +644,23 @@
|
||||
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
|
||||
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
|
||||
foreach (var o in existingOverrides)
|
||||
{
|
||||
_overrideValues[o.AttributeName] = o.OverrideValue;
|
||||
_existingOverrides[o.AttributeName] = o;
|
||||
}
|
||||
|
||||
// MV-14: seed the per-List working rows. A List attribute's editor is
|
||||
// initialized from the effective value — the existing override JSON if
|
||||
// present, otherwise the template default — decoded into string rows
|
||||
// using the element type fixed by the base attribute. A malformed
|
||||
// stored value falls back to empty rows (the editor still opens).
|
||||
foreach (var attr in _overrideAttrs.Where(a => a.DataType == DataType.List))
|
||||
{
|
||||
var effective = _existingOverrides.TryGetValue(attr.Name, out var ovr)
|
||||
? ovr.OverrideValue
|
||||
: attr.Value;
|
||||
_listRows[attr.Name] = DecodeListRows(effective, attr.ElementDataType);
|
||||
}
|
||||
|
||||
// Alarm overrides — load all non-locked template alarms and
|
||||
// existing override rows. Pre-seed the dirty maps from existing
|
||||
@@ -805,15 +870,123 @@
|
||||
else _overrideValues[attrName] = val;
|
||||
}
|
||||
|
||||
// ── MV-14: structured List (multi-value) overrides ──────────
|
||||
|
||||
/// <summary>Working rows for a List attribute's override (whole-list replacement).</summary>
|
||||
private List<string> GetListRows(string attrName)
|
||||
=> _listRows.TryGetValue(attrName, out var rows) ? rows : (_listRows[attrName] = new());
|
||||
|
||||
private void OnListRowsChanged(string attrName, List<string> rows)
|
||||
{
|
||||
_listRows[attrName] = rows;
|
||||
// A fresh edit clears any stale validation error for this attribute.
|
||||
_overrideErrors.Remove(attrName);
|
||||
}
|
||||
|
||||
/// <summary>True if a persisted override row exists for the attribute (so Clear is offered).</summary>
|
||||
private bool HasOverrideRow(string attrName) => _existingOverrides.ContainsKey(attrName);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a stored List JSON value into editable string rows using the
|
||||
/// element type fixed by the base attribute. A malformed stored value (e.g.
|
||||
/// hand-edited or an element-type mismatch) falls back to empty rows rather
|
||||
/// than crashing the editor — mirrors TemplateEdit.DecodeListRows.
|
||||
/// </summary>
|
||||
private static List<string> DecodeListRows(string? value, DataType? elementType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return new();
|
||||
try
|
||||
{
|
||||
var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String);
|
||||
if (decoded is System.Collections.IEnumerable items)
|
||||
return items.Cast<object?>()
|
||||
.Select(x => AttributeValueCodec.Encode(x) ?? string.Empty)
|
||||
.ToList();
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed stored value — start from empty so the editor still opens.
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a List attribute's override row entirely (repository-direct, the
|
||||
/// same pattern as native-alarm-source overrides) and resets the editor to
|
||||
/// the inherited template value.
|
||||
/// </summary>
|
||||
private async Task ClearListOverride(string attrName)
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
if (_existingOverrides.TryGetValue(attrName, out var ovr))
|
||||
{
|
||||
await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_existingOverrides.Remove(attrName);
|
||||
}
|
||||
_overrideValues.Remove(attrName);
|
||||
_overrideErrors.Remove(attrName);
|
||||
|
||||
// Reset the editor to the inherited template default.
|
||||
var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName);
|
||||
_listRows[attrName] = DecodeListRows(attr?.Value, attr?.ElementDataType);
|
||||
_toast.ShowSuccess($"Cleared override on '{attrName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Clear failed: {ex.Message}");
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
private async Task SaveOverrides()
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
_overrideErrors.Clear();
|
||||
var user = await GetCurrentUserAsync();
|
||||
foreach (var (attrName, value) in _overrideValues)
|
||||
await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
|
||||
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s).");
|
||||
|
||||
// Build the set of override values to persist. Scalars come straight
|
||||
// from the single-input map (unchanged). List attributes encode their
|
||||
// working rows to canonical JSON; each is round-tripped through Decode
|
||||
// first to surface any un-parseable element (mirrors TemplateEdit) —
|
||||
// an invalid element aborts the whole save and is shown inline.
|
||||
var toSave = new Dictionary<string, string?>(_overrideValues);
|
||||
var listAttrs = _overrideAttrs.Where(a => a.DataType == DataType.List).ToList();
|
||||
var hasError = false;
|
||||
foreach (var attr in listAttrs)
|
||||
{
|
||||
var elementType = attr.ElementDataType ?? DataType.String;
|
||||
var json = AttributeValueCodec.Encode(GetListRows(attr.Name));
|
||||
try { AttributeValueCodec.Decode(json, DataType.List, elementType); }
|
||||
catch (FormatException ex) { _overrideErrors[attr.Name] = ex.Message; hasError = true; continue; }
|
||||
toSave[attr.Name] = json;
|
||||
}
|
||||
|
||||
if (hasError)
|
||||
{
|
||||
_toast.ShowError("Some List overrides have invalid elements — see the highlighted rows.");
|
||||
_saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
foreach (var (attrName, value) in toSave)
|
||||
{
|
||||
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
|
||||
if (result.IsSuccess)
|
||||
_existingOverrides[attrName] = result.Value!;
|
||||
else
|
||||
failures.Add($"{attrName}: {result.Error}");
|
||||
}
|
||||
if (failures.Count > 0)
|
||||
_toast.ShowError($"Failed to save {failures.Count} override(s): {string.Join("; ", failures)}");
|
||||
var savedCount = toSave.Count - failures.Count;
|
||||
if (savedCount > 0)
|
||||
_toast.ShowSuccess($"Saved {savedCount} override(s).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@@ -80,6 +81,10 @@
|
||||
private string _attrName = string.Empty;
|
||||
private string? _attrValue;
|
||||
private DataType _attrDataType;
|
||||
// List-attribute authoring state (DataType.List only): the element scalar
|
||||
// type + the per-element string rows. Encoded to canonical JSON on submit.
|
||||
private DataType _attrElementDataType = DataType.String;
|
||||
private List<string> _attrListRows = new();
|
||||
private bool _attrIsLocked;
|
||||
private string? _attrDataSourceRef;
|
||||
private string? _attrFormError;
|
||||
@@ -553,17 +558,34 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Data Type</label>
|
||||
<select class="form-select" @bind="_attrDataType">
|
||||
<select class="form-select" value="@_attrDataType" @onchange="OnAttrDataTypeChanged" disabled="@editing">
|
||||
@foreach (var dt in Enum.GetValues<DataType>())
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
@if (editing)
|
||||
{
|
||||
<div class="form-text">Data type is fixed once the attribute is created.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" @bind="_attrValue" />
|
||||
</div>
|
||||
@if (_attrDataType == DataType.List)
|
||||
{
|
||||
<div class="col-12">
|
||||
@* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@
|
||||
<AttributeListEditor @bind-ElementDataType="_attrElementDataType"
|
||||
@bind-Rows="_attrListRows"
|
||||
ShowElementType="@(!editing)"
|
||||
Disabled="false" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-12">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" @bind="_attrValue" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Data Source Ref</label>
|
||||
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
|
||||
@@ -1535,6 +1557,8 @@
|
||||
_attrName = string.Empty;
|
||||
_attrValue = null;
|
||||
_attrDataType = default;
|
||||
_attrElementDataType = DataType.String;
|
||||
_attrListRows = new();
|
||||
_attrIsLocked = false;
|
||||
_attrDataSourceRef = null;
|
||||
}
|
||||
@@ -1547,6 +1571,8 @@
|
||||
_attrName = attr.Name;
|
||||
_attrValue = attr.Value;
|
||||
_attrDataType = attr.DataType;
|
||||
_attrElementDataType = attr.ElementDataType ?? DataType.String;
|
||||
_attrListRows = DecodeListRows(attr.Value, attr.ElementDataType);
|
||||
_attrIsLocked = attr.IsLocked;
|
||||
_attrDataSourceRef = attr.DataSourceReference;
|
||||
}
|
||||
@@ -1558,12 +1584,72 @@
|
||||
_attrFormError = null;
|
||||
}
|
||||
|
||||
// Switching the data type clears stale list state so a List ⇄ scalar
|
||||
// toggle never carries the other mode's value into the submit.
|
||||
private void OnAttrDataTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (!Enum.TryParse<DataType>((string?)e.Value, out var dt) || dt == _attrDataType) return;
|
||||
_attrDataType = dt;
|
||||
if (dt == DataType.List)
|
||||
{
|
||||
_attrValue = null;
|
||||
_attrListRows = new();
|
||||
if (!AttributeValueCodec.IsValidElementType(_attrElementDataType))
|
||||
_attrElementDataType = DataType.String;
|
||||
}
|
||||
else
|
||||
{
|
||||
_attrListRows = new();
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes a stored List JSON value into editable string rows. A malformed
|
||||
// stored value (e.g. hand-edited / element-type mismatch) is shown as empty
|
||||
// rather than crashing the editor — the user can rebuild it.
|
||||
private List<string> DecodeListRows(string? value, DataType? elementType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return new();
|
||||
try
|
||||
{
|
||||
var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String);
|
||||
if (decoded is System.Collections.IEnumerable items)
|
||||
return items.Cast<object?>()
|
||||
.Select(x => AttributeValueCodec.Encode(x) ?? string.Empty)
|
||||
.ToList();
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed stored value — start from empty so the editor still opens.
|
||||
}
|
||||
return new();
|
||||
}
|
||||
|
||||
private async Task SaveAttribute()
|
||||
{
|
||||
if (_selectedTemplate == null) return;
|
||||
_attrFormError = null;
|
||||
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
|
||||
|
||||
// Resolve the value + element type per data type. List attributes encode
|
||||
// their rows to canonical JSON and validate them locally before submit
|
||||
// (TemplateService persists directly and does not list-validate).
|
||||
string? attrValue;
|
||||
DataType? elementType;
|
||||
if (_attrDataType == DataType.List)
|
||||
{
|
||||
elementType = _attrElementDataType;
|
||||
attrValue = AttributeValueCodec.Encode(_attrListRows);
|
||||
// Round-trip through Decode to surface any un-parseable element
|
||||
// (e.g. non-numeric in an Int32 list) before hitting the server.
|
||||
try { AttributeValueCodec.Decode(attrValue, DataType.List, elementType); }
|
||||
catch (FormatException ex) { _attrFormError = ex.Message; return; }
|
||||
}
|
||||
else
|
||||
{
|
||||
elementType = null;
|
||||
attrValue = _attrValue?.Trim();
|
||||
}
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
|
||||
if (_editAttrId is int id)
|
||||
@@ -1573,7 +1659,8 @@
|
||||
var proposed = new TemplateAttribute(existing.Name)
|
||||
{
|
||||
DataType = _attrDataType,
|
||||
Value = _attrValue?.Trim(),
|
||||
ElementDataType = elementType,
|
||||
Value = attrValue,
|
||||
IsLocked = _attrIsLocked,
|
||||
DataSourceReference = _attrDataSourceRef?.Trim(),
|
||||
Description = existing.Description,
|
||||
@@ -1598,7 +1685,8 @@
|
||||
var attr = new TemplateAttribute(_attrName.Trim())
|
||||
{
|
||||
DataType = _attrDataType,
|
||||
Value = _attrValue?.Trim(),
|
||||
ElementDataType = elementType,
|
||||
Value = attrValue,
|
||||
IsLocked = _attrIsLocked,
|
||||
DataSourceReference = _attrDataSourceRef?.Trim()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
|
||||
@*
|
||||
Repeatable list-value editor for a structured multi-value (List) attribute.
|
||||
Reveals an element-type <select> (the six valid scalar types) plus one text
|
||||
input per element with add/remove controls. Binds a List<string> working
|
||||
model; the host encodes it to canonical JSON via AttributeValueCodec.Encode.
|
||||
|
||||
MV-14 reuses this component for instance overrides. Public API:
|
||||
- ElementDataType (two-way, bound) : the chosen element scalar.
|
||||
- Rows (two-way, bound) : the per-element string values.
|
||||
- ShowElementType (default true) : hide the type <select> when the
|
||||
element type is fixed (e.g. an
|
||||
instance override inherits it).
|
||||
- Disabled (default false) : render read-only.
|
||||
Both ElementDataTypeChanged and RowsChanged fire on every edit.
|
||||
*@
|
||||
|
||||
<div class="attribute-list-editor">
|
||||
@if (ShowElementType)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Element Type</label>
|
||||
<select class="form-select" value="@ElementDataType" @onchange="OnElementTypeChanged" disabled="@Disabled">
|
||||
@foreach (var dt in ElementTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="form-label">List Values</label>
|
||||
@if (Rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-2">No elements. Use “Add element” to add one.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-column gap-2 mb-2">
|
||||
@for (var i = 0; i < Rows.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text" style="min-width: 2.5rem;">@index</span>
|
||||
<input type="text" class="form-control font-monospace"
|
||||
value="@Rows[index]"
|
||||
placeholder="@Placeholder()"
|
||||
@oninput="e => OnRowInput(index, (string?)e.Value)"
|
||||
disabled="@Disabled" />
|
||||
@if (!Disabled)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
aria-label="@($"Remove element {index}")"
|
||||
@onclick="() => RemoveRow(index)">Remove</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Disabled)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddRow">Add element</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The chosen element scalar type. Two-way bound.</summary>
|
||||
[Parameter] public DataType ElementDataType { get; set; } = DataType.String;
|
||||
[Parameter] public EventCallback<DataType> ElementDataTypeChanged { get; set; }
|
||||
|
||||
/// <summary>The per-element string values. Two-way bound.</summary>
|
||||
[Parameter] public List<string> Rows { get; set; } = new();
|
||||
[Parameter] public EventCallback<List<string>> RowsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When false, the element-type <select> is hidden — used where the element
|
||||
/// type is fixed by the base attribute (e.g. instance overrides in MV-14).
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowElementType { get; set; } = true;
|
||||
|
||||
/// <summary>Render every control read-only.</summary>
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
|
||||
private static readonly DataType[] ElementTypes =
|
||||
Enum.GetValues<DataType>()
|
||||
.Where(AttributeValueCodec.IsValidElementType)
|
||||
.ToArray();
|
||||
|
||||
private async Task OnElementTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (Enum.TryParse<DataType>((string?)e.Value, out var dt) && dt != ElementDataType)
|
||||
{
|
||||
ElementDataType = dt;
|
||||
await ElementDataTypeChanged.InvokeAsync(dt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnRowInput(int index, string? value)
|
||||
{
|
||||
if (index < 0 || index >= Rows.Count) return;
|
||||
Rows[index] = value ?? string.Empty;
|
||||
await RowsChanged.InvokeAsync(Rows);
|
||||
}
|
||||
|
||||
private async Task AddRow()
|
||||
{
|
||||
Rows.Add(string.Empty);
|
||||
await RowsChanged.InvokeAsync(Rows);
|
||||
}
|
||||
|
||||
private async Task RemoveRow(int index)
|
||||
{
|
||||
if (index < 0 || index >= Rows.Count) return;
|
||||
Rows.RemoveAt(index);
|
||||
await RowsChanged.InvokeAsync(Rows);
|
||||
}
|
||||
|
||||
private string Placeholder() => ElementDataType switch
|
||||
{
|
||||
DataType.Int32 => "e.g. 42",
|
||||
DataType.Float => "e.g. 3.14",
|
||||
DataType.Double => "e.g. 3.14159",
|
||||
DataType.Boolean => "true / false",
|
||||
DataType.DateTime => "e.g. 2026-06-16T00:00:00Z",
|
||||
_ => "text value"
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
public class InstanceAttributeOverride
|
||||
@@ -10,6 +12,13 @@ public class InstanceAttributeOverride
|
||||
public string AttributeName { get; set; }
|
||||
/// <summary>Gets or sets the override value, or <c>null</c> to clear a previous override.</summary>
|
||||
public string? OverrideValue { get; set; }
|
||||
/// <summary>
|
||||
/// For <see cref="DataType.List"/> attributes: the scalar type of each
|
||||
/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar
|
||||
/// attributes. The element type is fixed by the base attribute and cannot be
|
||||
/// changed on a derived template or instance override.
|
||||
/// </summary>
|
||||
public DataType? ElementDataType { get; set; }
|
||||
|
||||
/// <summary>Initializes a new <see cref="InstanceAttributeOverride"/> for the given attribute name.</summary>
|
||||
/// <param name="attributeName">The name of the attribute to override.</param>
|
||||
|
||||
@@ -25,6 +25,13 @@ public class TemplateAttribute
|
||||
/// </summary>
|
||||
public DataType DataType { get; set; }
|
||||
/// <summary>
|
||||
/// For <see cref="Enums.DataType.List"/> attributes: the scalar type of each
|
||||
/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar
|
||||
/// attributes. The element type is fixed by the base attribute and cannot be
|
||||
/// changed on a derived template or instance override.
|
||||
/// </summary>
|
||||
public DataType? ElementDataType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is locked from override.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
@@ -8,8 +8,8 @@ public record DeleteTemplateCommand(int TemplateId);
|
||||
public record ValidateTemplateCommand(int TemplateId);
|
||||
|
||||
// Template member operations
|
||||
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked);
|
||||
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked);
|
||||
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||
public record DeleteTemplateAttributeCommand(int AttributeId);
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, round-trippable codec for attribute values. Scalars encode to an
|
||||
/// invariant-culture string (identical to the historical representation); List
|
||||
/// attributes encode to a JSON array. Used wherever a value is stored or
|
||||
/// transmitted (DB Value column, site SQLite, gRPC wire). <see cref="ValueFormatter"/>
|
||||
/// remains a separate, display-only (comma-joined) formatter.
|
||||
/// </summary>
|
||||
public static class AttributeValueCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||
|
||||
/// <summary>Encodes a value to its canonical string form.</summary>
|
||||
public static string? Encode(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: return null;
|
||||
case string s: return s; // already canonical
|
||||
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
|
||||
case IEnumerable e:
|
||||
var items = e.Cast<object?>()
|
||||
.Select(x => x is IFormattable xf
|
||||
? xf.ToString(null, CultureInfo.InvariantCulture)
|
||||
: x?.ToString());
|
||||
return JsonSerializer.Serialize(items, JsonOpts);
|
||||
default: return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a canonical string. For <see cref="DataType.List"/> returns a typed
|
||||
/// <c>List<T></c>; for scalars returns the string unchanged. Throws
|
||||
/// <see cref="FormatException"/> on malformed list JSON or an un-parseable element.
|
||||
/// </summary>
|
||||
public static object? Decode(string? value, DataType dataType, DataType? elementType)
|
||||
{
|
||||
if (dataType != DataType.List) return value; // scalar: unchanged
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (elementType is null)
|
||||
throw new FormatException("List attribute requires an element type.");
|
||||
|
||||
string?[] raw;
|
||||
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
|
||||
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
|
||||
|
||||
var clrType = ElementClrType(elementType.Value);
|
||||
var listType = typeof(List<>).MakeGenericType(clrType);
|
||||
var result = (IList)Activator.CreateInstance(listType)!;
|
||||
foreach (var item in raw)
|
||||
result.Add(ParseScalar(item, elementType.Value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Type ElementClrType(DataType t) => t switch
|
||||
{
|
||||
DataType.String => typeof(string),
|
||||
DataType.Int32 => typeof(int),
|
||||
DataType.Float => typeof(float),
|
||||
DataType.Double => typeof(double),
|
||||
DataType.Boolean => typeof(bool),
|
||||
DataType.DateTime => typeof(DateTime),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
|
||||
private static object? ParseScalar(string? s, DataType t)
|
||||
{
|
||||
if (s is null) throw new FormatException("List elements may not be null.");
|
||||
if (!IsValidElementType(t))
|
||||
throw new FormatException($"Unsupported list element type '{t}'.");
|
||||
var c = CultureInfo.InvariantCulture;
|
||||
try
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
DataType.String => s,
|
||||
DataType.Int32 => int.Parse(s, c),
|
||||
DataType.Float => float.Parse(s, c),
|
||||
DataType.Double => double.Parse(s, c),
|
||||
DataType.Boolean => bool.Parse(s),
|
||||
DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||
{
|
||||
throw new FormatException($"List element '{s}' is not a valid {t}.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the type may be a List element scalar.</summary>
|
||||
public static bool IsValidElementType(DataType t) =>
|
||||
t is DataType.String or DataType.Int32 or DataType.Float
|
||||
or DataType.Double or DataType.Boolean or DataType.DateTime;
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum DataType
|
||||
Double,
|
||||
String,
|
||||
DateTime,
|
||||
Binary
|
||||
Binary,
|
||||
List
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ public sealed record ResolvedAttribute
|
||||
public string? Value { get; init; }
|
||||
/// <summary>Gets the data type name.</summary>
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
/// <summary>For List attributes: the element scalar type name; null otherwise.</summary>
|
||||
public string? ElementDataType { get; init; }
|
||||
/// <summary>Gets whether the attribute is locked.</summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>Gets the attribute description.</summary>
|
||||
|
||||
@@ -45,7 +45,7 @@ public class StreamRelayActor : ReceiveActor
|
||||
InstanceUniqueName = msg.InstanceUniqueName,
|
||||
AttributePath = msg.AttributePath,
|
||||
AttributeName = msg.AttributeName,
|
||||
Value = ValueFormatter.FormatDisplayValue(msg.Value),
|
||||
Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,
|
||||
Quality = MapQuality(msg.Quality),
|
||||
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp)
|
||||
}
|
||||
|
||||
+6
-2
@@ -99,8 +99,12 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<I
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(o => o.OverrideValue)
|
||||
.HasMaxLength(4000);
|
||||
// nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars.
|
||||
builder.Property(o => o.OverrideValue);
|
||||
|
||||
builder.Property(o => o.ElementDataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique();
|
||||
}
|
||||
|
||||
+6
-2
@@ -108,8 +108,8 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateA
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Value)
|
||||
.HasMaxLength(4000);
|
||||
// nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars.
|
||||
builder.Property(a => a.Value);
|
||||
|
||||
builder.Property(a => a.Description)
|
||||
.HasMaxLength(2000);
|
||||
@@ -121,6 +121,10 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateA
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(a => a.ElementDataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
+1739
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddListAttributeElementType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Idempotent DDL: every statement is guarded so this migration is safe
|
||||
// to re-run against a partially-migrated DB (the #70 crash-loop lesson).
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes'))
|
||||
ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides'))
|
||||
ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
// ALTER COLUMN is naturally idempotent: re-running widens an already-widened column.
|
||||
migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Idempotent reverse DDL: drop the new columns only if present, then
|
||||
// restore the value columns to their original nvarchar(4000) width.
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes'))
|
||||
ALTER TABLE [TemplateAttributes] DROP COLUMN [ElementDataType];");
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides'))
|
||||
ALTER TABLE [InstanceAttributeOverrides] DROP COLUMN [ElementDataType];");
|
||||
migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(4000) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(4000) NULL;");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -563,12 +563,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("ElementDataType")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OverrideValue")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -1164,6 +1167,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("ElementDataType")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<bool>("IsInherited")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -1182,8 +1189,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
|
||||
@@ -524,6 +524,7 @@ public class ManagementActor : ReceiveActor
|
||||
CanonicalName = a.Name,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType.ToString(),
|
||||
ElementDataType = a.ElementDataType?.ToString(),
|
||||
IsLocked = a.IsLocked,
|
||||
DataSourceReference = a.DataSourceReference
|
||||
}).ToList(),
|
||||
@@ -1441,9 +1442,13 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
var dataType = ParseDataType(cmd.DataType);
|
||||
var elementType = ParseElementDataType(cmd.ElementDataType);
|
||||
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
|
||||
var attr = new TemplateAttribute(cmd.Name)
|
||||
{
|
||||
DataType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
|
||||
DataType = dataType,
|
||||
ElementDataType = elementType,
|
||||
Value = cmd.Value,
|
||||
Description = cmd.Description,
|
||||
DataSourceReference = cmd.DataSourceReference,
|
||||
@@ -1456,9 +1461,13 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
var dataType = ParseDataType(cmd.DataType);
|
||||
var elementType = ParseElementDataType(cmd.ElementDataType);
|
||||
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
|
||||
var attr = new TemplateAttribute(cmd.Name)
|
||||
{
|
||||
DataType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
|
||||
DataType = dataType,
|
||||
ElementDataType = elementType,
|
||||
Value = cmd.Value,
|
||||
Description = cmd.Description,
|
||||
DataSourceReference = cmd.DataSourceReference,
|
||||
@@ -1468,6 +1477,70 @@ public class ManagementActor : ReceiveActor
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a required data type token. Throws <see cref="ManagementCommandException"/>
|
||||
/// (surfaced to the caller as a curated COMMAND_FAILED message) on an
|
||||
/// unrecognised type name, rather than letting <c>Enum.Parse</c>'s
|
||||
/// <see cref="ArgumentException"/> be masked as a generic internal error.
|
||||
/// </summary>
|
||||
private static Commons.Types.Enums.DataType ParseDataType(string? dataType)
|
||||
{
|
||||
if (!Enum.TryParse<Commons.Types.Enums.DataType>(dataType, ignoreCase: true, out var parsed))
|
||||
throw new ManagementCommandException($"Unrecognised data type '{dataType}'.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an optional element data type token. Returns null when the token is
|
||||
/// empty/whitespace; throws <see cref="ManagementCommandException"/> on an
|
||||
/// unrecognised type name.
|
||||
/// </summary>
|
||||
private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(elementDataType)) return null;
|
||||
if (!Enum.TryParse<Commons.Types.Enums.DataType>(elementDataType, ignoreCase: true, out var parsed))
|
||||
throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the (DataType, ElementDataType, Value) triple shared by the add
|
||||
/// and update attribute handlers. Throws <see cref="ManagementCommandException"/>
|
||||
/// on any violation:
|
||||
/// <list type="bullet">
|
||||
/// <item>List attributes require a valid scalar element type.</item>
|
||||
/// <item>Scalar attributes may not carry an element type.</item>
|
||||
/// <item>A List default value must decode against the declared element type.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static void ValidateAttributeTypes(
|
||||
string name, Commons.Types.Enums.DataType dataType, Commons.Types.Enums.DataType? elementType, string? value)
|
||||
{
|
||||
if (dataType == Commons.Types.Enums.DataType.List)
|
||||
{
|
||||
if (elementType is null || !Commons.Types.AttributeValueCodec.IsValidElementType(elementType.Value))
|
||||
throw new ManagementCommandException(
|
||||
$"List attribute '{name}' requires a valid element type (String, Int32, Float, Double, Boolean, DateTime).");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
try
|
||||
{
|
||||
Commons.Types.AttributeValueCodec.Decode(value, Commons.Types.Enums.DataType.List, elementType);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
$"List attribute '{name}' has an invalid list value: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (elementType is not null)
|
||||
{
|
||||
throw new ManagementCommandException($"Attribute '{name}': ElementDataType is only valid on List attributes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
|
||||
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
@@ -59,6 +60,14 @@ public class InstanceActor : ReceiveActor
|
||||
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
|
||||
private FlattenedConfiguration? _configuration;
|
||||
|
||||
// MV-8: resolved attributes indexed by canonical name. The TagValueUpdate
|
||||
// ingest path is the highest-frequency message this actor handles, so the
|
||||
// attribute lookup must be O(1) rather than a linear scan of
|
||||
// _configuration.Attributes. Built once in the constructor from the
|
||||
// deserialized configuration (last-wins on duplicate canonical names,
|
||||
// mirroring the rest of the actor's by-name dictionaries).
|
||||
private readonly Dictionary<string, ResolvedAttribute> _resolvedAttributeByName = new();
|
||||
|
||||
// DCL manager actor reference for subscribing to tag values
|
||||
private readonly IActorRef? _dclManager;
|
||||
// Maps each tag path to every attribute canonical name that references it.
|
||||
@@ -115,9 +124,29 @@ public class InstanceActor : ReceiveActor
|
||||
{
|
||||
foreach (var attr in _configuration.Attributes)
|
||||
{
|
||||
_attributes[attr.CanonicalName] = attr.Value;
|
||||
_attributeQualities[attr.CanonicalName] =
|
||||
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
|
||||
// MV-8: index resolved attributes for O(1) lookup on the hot
|
||||
// TagValueUpdate ingest path (last-wins on duplicate names).
|
||||
_resolvedAttributeByName[attr.CanonicalName] = attr;
|
||||
|
||||
// MV-7: a STATIC List attribute's default is the canonical JSON
|
||||
// array string. Decode it to a typed List<T> for in-memory reads
|
||||
// so scripts see a real collection. Scalars store their raw
|
||||
// string unchanged. A malformed List default decodes to null and
|
||||
// is marked Bad quality rather than crashing the actor.
|
||||
if (IsListAttribute(attr))
|
||||
{
|
||||
var decoded = DecodeAttributeValue(attr, attr.Value);
|
||||
_attributes[attr.CanonicalName] = decoded;
|
||||
_attributeQualities[attr.CanonicalName] =
|
||||
decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad"
|
||||
: string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
|
||||
}
|
||||
else
|
||||
{
|
||||
_attributes[attr.CanonicalName] = attr.Value;
|
||||
_attributeQualities[attr.CanonicalName] =
|
||||
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +335,47 @@ public class InstanceActor : ReceiveActor
|
||||
/// </summary>
|
||||
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
|
||||
{
|
||||
_attributes[command.AttributeName] = command.Value;
|
||||
// MV-7: command.Value is the canonical form — a plain string for scalars,
|
||||
// a JSON array string for List attributes. For a List attribute we store
|
||||
// the DECODED typed list in memory (so scripts read a real collection) but
|
||||
// persist + publish the canonical JSON string UNCHANGED below. Scalars
|
||||
// store the string verbatim. (HandleSetStaticAttribute already rejected
|
||||
// unknown attributes, so resolved is non-null here, but guard defensively.)
|
||||
if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved)
|
||||
&& IsListAttribute(resolved))
|
||||
{
|
||||
// MV-7: the script path pre-encodes valid canonical JSON via ScopeAccessors,
|
||||
// but the Inbound API / direct-command path can submit an arbitrary
|
||||
// command.Value. A non-empty value that fails to decode (malformed JSON,
|
||||
// bad element, missing element type) is poison: storing it would null the
|
||||
// in-memory value yet publish "Good" quality and durably persist the bad
|
||||
// JSON (which then loads as Bad next restart). Reject such writes outright.
|
||||
// Note: DecodeAttributeValue returns null for BOTH a null/empty input
|
||||
// (valid — clearing) AND a malformed non-empty input (invalid). Only the
|
||||
// latter is rejected, hence the explicit IsNullOrWhiteSpace guard. An empty
|
||||
// list "[]" decodes to a non-null empty List, so it passes through.
|
||||
var decoded = DecodeAttributeValue(resolved, command.Value);
|
||||
if (!string.IsNullOrWhiteSpace(command.Value) && decoded == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SetAttribute rejected — value for List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||||
command.AttributeName, _instanceUniqueName);
|
||||
Sender.Tell(new SetStaticAttributeResponse(
|
||||
command.CorrelationId,
|
||||
_instanceUniqueName,
|
||||
command.AttributeName,
|
||||
false,
|
||||
$"Invalid list value for attribute '{command.AttributeName}'",
|
||||
DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
_attributes[command.AttributeName] = decoded;
|
||||
}
|
||||
else
|
||||
{
|
||||
_attributes[command.AttributeName] = command.Value;
|
||||
}
|
||||
|
||||
// Publish attribute change to stream (WP-23) and notify children
|
||||
var changed = new AttributeValueChanged(
|
||||
@@ -362,11 +431,37 @@ public class InstanceActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// MV (C1): for a data-sourced List attribute the incoming command.Value is
|
||||
// the canonical JSON array string (ScopeAccessors encodes the script's
|
||||
// List<T> for transport/storage). Writing that string straight to the DCL
|
||||
// would push a String scalar to an array node. Decode it back to a typed
|
||||
// List<T> so the DCL/Variant write produces a real array. A non-empty value
|
||||
// that fails to decode (malformed JSON / bad element) is poison — reject the
|
||||
// write rather than forward garbage to the device (mirrors the static-path
|
||||
// rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
|
||||
object? writeValue = command.Value;
|
||||
if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(command.Value))
|
||||
{
|
||||
var decoded = DecodeAttributeValue(resolved, command.Value);
|
||||
if (decoded == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||||
attributeName, instanceName);
|
||||
caller.Tell(new SetStaticAttributeResponse(
|
||||
correlationId, instanceName, attributeName, false,
|
||||
$"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow));
|
||||
return;
|
||||
}
|
||||
|
||||
writeValue = decoded;
|
||||
}
|
||||
|
||||
var writeRequest = new WriteTagRequest(
|
||||
correlationId,
|
||||
resolved.BoundDataConnectionName!,
|
||||
resolved.DataSourceReference!,
|
||||
command.Value,
|
||||
writeValue,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Ask the DCL and pipe the result back to the original caller. The DCL
|
||||
@@ -433,21 +528,184 @@ public class InstanceActor : ReceiveActor
|
||||
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
|
||||
return;
|
||||
|
||||
// Normalize array values to JSON strings so they survive Akka serialization
|
||||
var value = update.Value is Array
|
||||
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
||||
: update.Value;
|
||||
|
||||
// One tag path may back several attributes — update every one of them.
|
||||
// Each attribute is coerced according to its own declared data type, so
|
||||
// we resolve and convert per attribute rather than once for the tag.
|
||||
foreach (var attrName in attrNames)
|
||||
{
|
||||
var changed = new AttributeValueChanged(
|
||||
// MV-8: O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
|
||||
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
|
||||
|
||||
// MV-8: a List-typed attribute coerces the incoming OPC UA array
|
||||
// (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
|
||||
// element-type mismatch we set the attribute's quality to Bad, log a
|
||||
// warning, and skip storing a value rather than crashing the actor.
|
||||
if (resolved != null && IsListAttribute(resolved))
|
||||
{
|
||||
if (TryCoerceListValue(resolved, update.Value, out var typedList))
|
||||
{
|
||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
typedList, update.Quality.ToString(), update.Timestamp));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad",
|
||||
_instanceUniqueName, attrName, resolved.ElementDataType);
|
||||
_attributeQualities[attrName] = "Bad";
|
||||
_attributeTimestamps[attrName] = update.Timestamp;
|
||||
var currentValue = _attributes.GetValueOrDefault(attrName);
|
||||
PublishAndNotifyChildren(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
currentValue, "Bad", update.Timestamp));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scalars and non-List attributes keep the historical behaviour:
|
||||
// array values are normalized to JSON strings so they survive Akka
|
||||
// serialization; scalars pass through unchanged.
|
||||
var value = update.Value is Array
|
||||
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
||||
: update.Value;
|
||||
|
||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
value, update.Quality.ToString(), update.Timestamp);
|
||||
HandleAttributeValueChanged(changed);
|
||||
value, update.Quality.ToString(), update.Timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the resolved attribute is declared as a <see cref="DataType.List"/>.</summary>
|
||||
private static bool IsListAttribute(ResolvedAttribute attr) =>
|
||||
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
||||
&& dt == DataType.List;
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: decodes a STATIC (authored / overridden) attribute's canonical value
|
||||
/// for in-memory storage. List attributes carry a canonical JSON array string
|
||||
/// (config default or persisted override) which is decoded via
|
||||
/// <see cref="AttributeValueCodec.Decode"/> into a typed <c>List<T></c>
|
||||
/// so scripts read a real collection; scalars pass through unchanged. This is
|
||||
/// the authored counterpart to MV-8's <see cref="TryCoerceListValue"/> (which
|
||||
/// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON,
|
||||
/// bad element, missing element type) degrades to <see langword="null"/> + a
|
||||
/// warning — the caller marks the attribute Bad quality. NEVER throws into the
|
||||
/// actor.
|
||||
/// </summary>
|
||||
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
|
||||
{
|
||||
DataType dataType = Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
||||
? dt
|
||||
: DataType.String;
|
||||
DataType? elementType = string.IsNullOrEmpty(attr.ElementDataType)
|
||||
? null
|
||||
: (Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var et) ? et : null);
|
||||
|
||||
try
|
||||
{
|
||||
return AttributeValueCodec.Decode(raw, dataType, elementType);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality",
|
||||
attr.CanonicalName, _instanceUniqueName);
|
||||
return null; // caller marks quality Bad
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
|
||||
/// into a typed <c>List<elementClrType></c> matching the attribute's
|
||||
/// <see cref="ResolvedAttribute.ElementDataType"/>. Each element is converted
|
||||
/// with invariant culture (round-trip parse for DateTime). Returns
|
||||
/// <see langword="false"/> on a missing/invalid element type, a non-enumerable
|
||||
/// value, or any element that cannot be coerced — the caller then marks the
|
||||
/// attribute quality Bad. Never throws.
|
||||
/// </summary>
|
||||
private bool TryCoerceListValue(ResolvedAttribute attr, object? incoming, out object? typedList)
|
||||
{
|
||||
typedList = null;
|
||||
|
||||
if (string.IsNullOrEmpty(attr.ElementDataType)
|
||||
|| !Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
|
||||
|| !AttributeValueCodec.IsValidElementType(elementType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (incoming is not System.Collections.IEnumerable enumerable || incoming is string)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
// Construct the typed list INSIDE the try: although the six valid
|
||||
// element types resolved by ListElementClrType cannot throw today,
|
||||
// keeping ListElementClrType / MakeGenericType / CreateInstance inside
|
||||
// the guarded block means any future change that introduces a throw
|
||||
// here is caught and turned into a Bad-quality result rather than
|
||||
// escaping into the actor and tripping supervision.
|
||||
var clrType = ListElementClrType(elementType);
|
||||
var list = (System.Collections.IList)Activator.CreateInstance(
|
||||
typeof(List<>).MakeGenericType(clrType))!;
|
||||
|
||||
foreach (var element in enumerable)
|
||||
list.Add(CoerceElement(element, elementType));
|
||||
|
||||
typedList = list;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any coercion / construction failure → Bad quality, never a crash.
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to coerce value to List<{Element}> for instance {Instance}; marking quality Bad",
|
||||
attr.ElementDataType, _instanceUniqueName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Type ListElementClrType(DataType t) => t switch
|
||||
{
|
||||
DataType.String => typeof(string),
|
||||
DataType.Int32 => typeof(int),
|
||||
DataType.Float => typeof(float),
|
||||
DataType.Double => typeof(double),
|
||||
DataType.Boolean => typeof(bool),
|
||||
DataType.DateTime => typeof(DateTime),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
|
||||
private static object CoerceElement(object? element, DataType t)
|
||||
{
|
||||
if (element is null)
|
||||
throw new FormatException("List elements may not be null.");
|
||||
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
return t switch
|
||||
{
|
||||
DataType.String => Convert.ToString(element, culture)
|
||||
?? throw new FormatException("Null string element."),
|
||||
DataType.Int32 => element is string si
|
||||
? int.Parse(si, culture)
|
||||
: Convert.ToInt32(element, culture),
|
||||
DataType.Float => element is string sf
|
||||
? float.Parse(sf, culture)
|
||||
: Convert.ToSingle(element, culture),
|
||||
DataType.Double => element is string sd
|
||||
? double.Parse(sd, culture)
|
||||
: Convert.ToDouble(element, culture),
|
||||
DataType.Boolean => element is string sb
|
||||
? bool.Parse(sb)
|
||||
: Convert.ToBoolean(element, culture),
|
||||
DataType.DateTime => element is string sdt
|
||||
? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind)
|
||||
: Convert.ToDateTime(element, culture),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
||||
{
|
||||
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
||||
@@ -683,7 +941,22 @@ public class InstanceActor : ReceiveActor
|
||||
|
||||
foreach (var kvp in result.Overrides)
|
||||
{
|
||||
_attributes[kvp.Key] = kvp.Value;
|
||||
// MV-7: persisted override values are canonical strings — a JSON array
|
||||
// string for List attributes, a plain string for scalars. Decode List
|
||||
// overrides to a typed list (matching the config-default load), set
|
||||
// Bad quality on a malformed stored value, and never crash the actor.
|
||||
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
|
||||
&& IsListAttribute(resolved))
|
||||
{
|
||||
var decoded = DecodeAttributeValue(resolved, kvp.Value);
|
||||
_attributes[kvp.Key] = decoded;
|
||||
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
|
||||
_attributeQualities[kvp.Key] = "Bad";
|
||||
}
|
||||
else
|
||||
{
|
||||
_attributes[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
@@ -53,7 +55,7 @@ public class AttributeAccessor
|
||||
// on the DCL round-trip for data-connected attributes. The async
|
||||
// variants (GetAsync/SetAsync) are preferred where awaiting is possible.
|
||||
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
|
||||
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty).GetAwaiter().GetResult();
|
||||
set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,7 +72,7 @@ public class AttributeAccessor
|
||||
/// <param name="value">The value to set.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task SetAsync(string key, object? value)
|
||||
=> _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -175,6 +175,7 @@ public class FlatteningService
|
||||
CanonicalName = attr.Name,
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType.ToString(),
|
||||
ElementDataType = attr.ElementDataType?.ToString(),
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
/// overrides that were previously blocked (TemplateEngine-022).
|
||||
///
|
||||
/// Override granularity:
|
||||
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
|
||||
/// - Attributes: Value and Description overridable; DataType, ElementDataType and DataSourceReference fixed.
|
||||
/// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed.
|
||||
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, ExecutionTimeoutSeconds, params/return overridable; Name fixed.
|
||||
/// - Lock flag applies to the entire member (attribute/alarm/script).
|
||||
@@ -43,6 +43,12 @@ public static class LockEnforcer
|
||||
return $"Attribute '{original.Name}': DataType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// ElementDataType is fixed (the element scalar type of a List attribute) — cannot change
|
||||
if (proposed.ElementDataType != original.ElementDataType)
|
||||
{
|
||||
return $"Attribute '{original.Name}': ElementDataType cannot be overridden (fixed).";
|
||||
}
|
||||
|
||||
// DataSourceReference is fixed — cannot change
|
||||
if (proposed.DataSourceReference != original.DataSourceReference)
|
||||
{
|
||||
|
||||
@@ -982,6 +982,7 @@ public class TemplateService
|
||||
{
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType,
|
||||
ElementDataType = attr.ElementDataType,
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
@@ -48,6 +50,11 @@ public class SemanticValidator
|
||||
attributeMap.TryAdd(a.CanonicalName, a);
|
||||
}
|
||||
|
||||
// List-attribute type semantics (MV-5): element-type cardinality + default
|
||||
// value parseability. Trigger-operand rejection (rule 3) is handled below
|
||||
// by the existing NumericDataTypes guard (List is never numeric).
|
||||
ValidateListAttributes(configuration, errors);
|
||||
|
||||
// Collect alarm on-trigger script names for cross-call violation checks
|
||||
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
@@ -250,6 +257,77 @@ public class SemanticValidator
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-5 — semantic validation of List-attribute type configuration. Two rules:
|
||||
/// <list type="number">
|
||||
/// <item><b>Element-type cardinality.</b> A <see cref="DataType.List"/> attribute
|
||||
/// must carry a non-empty <see cref="ResolvedAttribute.ElementDataType"/> that is
|
||||
/// a valid element scalar (see <see cref="AttributeValueCodec.IsValidElementType"/>);
|
||||
/// a non-List attribute must NOT carry an element type.</item>
|
||||
/// <item><b>Default-value parseability.</b> A non-empty authored default
|
||||
/// <see cref="ResolvedAttribute.Value"/> on a List attribute must
|
||||
/// <see cref="AttributeValueCodec.Decode"/> without throwing.</item>
|
||||
/// </list>
|
||||
/// Attributes whose <see cref="ResolvedAttribute.DataType"/> doesn't parse to a
|
||||
/// known <see cref="DataType"/> are skipped here (their data type is not "List",
|
||||
/// so only the "no element type" half could apply, and an unparseable type is a
|
||||
/// separate concern not introduced by this feature).
|
||||
/// </summary>
|
||||
private static void ValidateListAttributes(
|
||||
FlattenedConfiguration configuration,
|
||||
List<ValidationEntry> errors)
|
||||
{
|
||||
foreach (var attr in configuration.Attributes)
|
||||
{
|
||||
var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase);
|
||||
var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType);
|
||||
|
||||
// ── Rule 1: element-type cardinality ─────────────────────────────
|
||||
if (!isList)
|
||||
{
|
||||
if (hasElementType)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
|
||||
$"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.",
|
||||
attr.CanonicalName));
|
||||
}
|
||||
continue; // Non-List attributes have no list-specific value to check.
|
||||
}
|
||||
|
||||
if (!hasElementType)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
|
||||
$"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).",
|
||||
attr.CanonicalName));
|
||||
continue; // Without an element type we can't validate the default value.
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
|
||||
|| !AttributeValueCodec.IsValidElementType(elementType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
|
||||
$"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).",
|
||||
attr.CanonicalName));
|
||||
continue; // A bad element type makes the default-value check meaningless.
|
||||
}
|
||||
|
||||
// ── Rule 2: default-value parseability ───────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(attr.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
AttributeValueCodec.Decode(attr.Value, DataType.List, elementType);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
|
||||
$"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}",
|
||||
attr.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCallParameters(
|
||||
string callerName,
|
||||
CallTarget call,
|
||||
|
||||
@@ -18,7 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
/// and (on the deploy path) the bound connection must exist on the target site.
|
||||
/// Severity is context-dependent: a non-blocking Warning at template design time
|
||||
/// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23).
|
||||
/// 8. Does NOT verify tag path resolution on devices
|
||||
/// 8. List-attribute type semantics (MV-5, via <see cref="SemanticValidator"/>):
|
||||
/// element-type cardinality, default-value parseability, and trigger-operand
|
||||
/// rejection for List attributes.
|
||||
/// 9. Does NOT verify tag path resolution on devices
|
||||
/// </summary>
|
||||
public class ValidationService
|
||||
{
|
||||
|
||||
@@ -1045,6 +1045,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
ElementDataType = a.ElementDataType,
|
||||
});
|
||||
}
|
||||
foreach (var al in dto.Alarms)
|
||||
@@ -1082,8 +1083,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// enumerates for the "Template overwritten" action.
|
||||
/// <para>
|
||||
/// Update detection compares every scalar field (Value, DataType,
|
||||
/// IsLocked, Description, DataSourceReference) — no field change → no
|
||||
/// audit row, so an idempotent overwrite produces no noise.
|
||||
/// ElementDataType, IsLocked, Description, DataSourceReference) — no field
|
||||
/// change → no audit row, so an idempotent overwrite produces no noise.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task SyncTemplateAttributesAsync(
|
||||
@@ -1122,7 +1123,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
current.DataType != attrDto.DataType ||
|
||||
current.IsLocked != attrDto.IsLocked ||
|
||||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal);
|
||||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) ||
|
||||
current.ElementDataType != attrDto.ElementDataType;
|
||||
if (!changed) continue;
|
||||
|
||||
current.Value = attrDto.Value;
|
||||
@@ -1130,6 +1132,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
current.IsLocked = attrDto.IsLocked;
|
||||
current.Description = attrDto.Description;
|
||||
current.DataSourceReference = attrDto.DataSourceReference;
|
||||
current.ElementDataType = attrDto.ElementDataType;
|
||||
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
@@ -1143,6 +1146,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
AttributeName = current.Name,
|
||||
current.Value,
|
||||
current.DataType,
|
||||
current.ElementDataType,
|
||||
current.IsLocked,
|
||||
current.Description,
|
||||
current.DataSourceReference,
|
||||
@@ -1158,6 +1162,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
IsLocked = attrDto.IsLocked,
|
||||
Description = attrDto.Description,
|
||||
DataSourceReference = attrDto.DataSourceReference,
|
||||
ElementDataType = attrDto.ElementDataType,
|
||||
};
|
||||
ex.Attributes.Add(newAttr);
|
||||
await _auditService.LogAsync(
|
||||
@@ -1172,6 +1177,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
AttributeName = newAttr.Name,
|
||||
newAttr.Value,
|
||||
newAttr.DataType,
|
||||
newAttr.ElementDataType,
|
||||
newAttr.IsLocked,
|
||||
newAttr.Description,
|
||||
newAttr.DataSourceReference,
|
||||
@@ -2300,6 +2306,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
CanonicalName = a.Name,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType.ToString(),
|
||||
ElementDataType = a.ElementDataType?.ToString(),
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
|
||||
@@ -80,7 +80,8 @@ public sealed record TemplateAttributeDto(
|
||||
DataType DataType,
|
||||
bool IsLocked,
|
||||
string? Description,
|
||||
string? DataSourceReference);
|
||||
string? DataSourceReference,
|
||||
DataType? ElementDataType = null);
|
||||
|
||||
public sealed record TemplateAlarmDto(
|
||||
string Name,
|
||||
|
||||
@@ -51,7 +51,8 @@ public sealed class EntitySerializer
|
||||
DataType: a.DataType,
|
||||
IsLocked: a.IsLocked,
|
||||
Description: a.Description,
|
||||
DataSourceReference: a.DataSourceReference)).ToList(),
|
||||
DataSourceReference: a.DataSourceReference,
|
||||
ElementDataType: a.ElementDataType)).ToList(),
|
||||
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
@@ -203,6 +204,7 @@ public sealed class EntitySerializer
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
ElementDataType = a.ElementDataType,
|
||||
});
|
||||
}
|
||||
foreach (var al in dto.Alarms)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// MV-11: the <c>template attribute add</c> / <c>update</c> commands must support
|
||||
/// structured multi-value (List) attributes — a new <c>--element-type</c> option,
|
||||
/// a JSON-array <c>--value</c>, client-side element-type validation, and
|
||||
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
|
||||
/// <see cref="UpdateTemplateAttributeCommand.ElementDataType"/> wired into the
|
||||
/// payload sent to the Management API.
|
||||
/// </summary>
|
||||
public class TemplateAttributeListTests
|
||||
{
|
||||
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
|
||||
|
||||
private static Command AttributeGroup()
|
||||
=> TemplateCommands.Build(Url, Format, Username, Password)
|
||||
.Subcommands.Single(c => c.Name == "attribute");
|
||||
|
||||
// ---- option surface ----
|
||||
|
||||
[Fact]
|
||||
public void AttributeAdd_HasElementTypeOption()
|
||||
{
|
||||
var add = AttributeGroup().Subcommands.Single(c => c.Name == "add");
|
||||
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeUpdate_HasElementTypeOption()
|
||||
{
|
||||
var update = AttributeGroup().Subcommands.Single(c => c.Name == "update");
|
||||
Assert.Contains("--element-type", update.Options.Select(o => o.Name));
|
||||
}
|
||||
|
||||
// ---- client-side element-type validation (both directions) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("String")]
|
||||
[InlineData("Int32")]
|
||||
[InlineData("Float")]
|
||||
[InlineData("Double")]
|
||||
[InlineData("Boolean")]
|
||||
[InlineData("DateTime")]
|
||||
public void ValidateElementType_ListWithValidScalar_Ok(string elementType)
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("List", elementType, out var error);
|
||||
Assert.True(ok);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("List", "string", out var error);
|
||||
Assert.True(ok);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithoutElementType_Error()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("List", null, out var error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBlankElementType_Error()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("List", " ", out var error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithInvalidScalar_Error()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("List", "List", out var error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ListWithBinaryScalar_Error()
|
||||
{
|
||||
// Binary is a DataType but not a permitted List element scalar.
|
||||
var ok = TemplateCommands.TryValidateElementType("List", "Binary", out var error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithElementType_Error()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("String", "Int32", out var error);
|
||||
Assert.False(ok);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateElementType_ScalarWithoutElementType_Ok()
|
||||
{
|
||||
var ok = TemplateCommands.TryValidateElementType("Float", null, out var error);
|
||||
Assert.True(ok);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
|
||||
|
||||
[Fact]
|
||||
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
var cmd = TemplateCommands.BuildAddAttributeCommand(
|
||||
templateId: 7,
|
||||
name: "WorkOrders",
|
||||
dataType: "List",
|
||||
value: """["WO-1","WO-2"]""",
|
||||
description: null,
|
||||
dataSource: null,
|
||||
isLocked: false,
|
||||
elementType: "String");
|
||||
|
||||
Assert.Equal(7, cmd.TemplateId);
|
||||
Assert.Equal("List", cmd.DataType);
|
||||
Assert.Equal("String", cmd.ElementDataType);
|
||||
// The CLI forwards the raw JSON string unchanged — the API/codec parses it.
|
||||
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
|
||||
{
|
||||
var cmd = TemplateCommands.BuildUpdateAttributeCommand(
|
||||
attributeId: 42,
|
||||
name: "WorkOrders",
|
||||
dataType: "List",
|
||||
value: """["A","B"]""",
|
||||
description: null,
|
||||
dataSource: null,
|
||||
isLocked: false,
|
||||
elementType: "Int32");
|
||||
|
||||
Assert.Equal(42, cmd.AttributeId);
|
||||
Assert.Equal("List", cmd.DataType);
|
||||
Assert.Equal("Int32", cmd.ElementDataType);
|
||||
Assert.Equal("""["A","B"]""", cmd.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
|
||||
{
|
||||
var cmd = TemplateCommands.BuildAddAttributeCommand(
|
||||
templateId: 1,
|
||||
name: "Speed",
|
||||
dataType: "Float",
|
||||
value: "0",
|
||||
description: null,
|
||||
dataSource: null,
|
||||
isLocked: false,
|
||||
elementType: null);
|
||||
|
||||
Assert.Null(cmd.ElementDataType);
|
||||
Assert.Equal("Float", cmd.DataType);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// MV-14: the Instance Configure attribute-override panel uses the shared
|
||||
/// <c>AttributeListEditor</c> for a List attribute (whole-list replacement; the
|
||||
/// element type is fixed by the base attribute, so the type select is hidden via
|
||||
/// <c>ShowElementType="false"</c>). Loading an existing override decodes its JSON
|
||||
/// into rows; saving encodes the rows back to canonical JSON with a pre-submit
|
||||
/// round-trip guard; clearing removes the override row. <c>InstanceConfigure</c>
|
||||
/// is a heavyweight page (multiple injected services incl. <c>InstanceService</c>
|
||||
/// and the flattening pipeline), so — consistent with the native-alarm and
|
||||
/// template-editor coverage — these are structural assertions over the component
|
||||
/// source that pin the wiring, plus a real codec round-trip mirroring what the
|
||||
/// page does on load/save.
|
||||
/// </summary>
|
||||
public class InstanceConfigureListOverrideTests
|
||||
{
|
||||
private static string InstanceConfigureMarkup
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Deployment", "InstanceConfigure.razor"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListOverride_RevealsSharedEditor_WithElementTypeHidden()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
// Conditional reveal on a List attribute.
|
||||
Assert.Contains("attr.DataType == DataType.List", markup);
|
||||
Assert.Contains("<AttributeListEditor", markup);
|
||||
// Element type is fixed by the base attribute → type select hidden.
|
||||
Assert.Contains("ShowElementType=\"false\"", markup);
|
||||
Assert.Contains("ElementDataType=\"@(attr.ElementDataType ?? DataType.String)\"", markup);
|
||||
// Bound to the per-attribute working rows.
|
||||
Assert.Contains("Rows=\"@GetListRows(attr.Name)\"", markup);
|
||||
Assert.Contains("RowsChanged=\"@(r => OnListRowsChanged(attr.Name, r))\"", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListOverride_DecodesOnLoad_AndEncodesOnSaveWithGuard()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
// Load: effective value (existing override JSON or template default)
|
||||
// decoded into rows via the shared codec, malformed → empty rows.
|
||||
Assert.Contains("DecodeListRows(", markup);
|
||||
Assert.Contains("catch (FormatException)", markup);
|
||||
// Save: rows encoded to canonical JSON + round-trip Decode guard.
|
||||
Assert.Contains("AttributeValueCodec.Encode(GetListRows(", markup);
|
||||
Assert.Contains("AttributeValueCodec.Decode(json, DataType.List, elementType)", markup);
|
||||
Assert.Contains("_overrideErrors", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListOverride_ClearRemovesTheOverrideRow()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
Assert.Contains("ClearListOverride", markup);
|
||||
// Repository-direct delete (the page only edits InstanceConfigure; no new
|
||||
// server method) — same pattern as native-alarm-source overrides.
|
||||
Assert.Contains("DeleteInstanceAttributeOverrideAsync", markup);
|
||||
Assert.Contains("HasOverrideRow", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonListOverride_KeepsSingleInputUx()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
// The scalar path still binds the single text input via the existing helpers.
|
||||
Assert.Contains("GetOverrideValue(attr.Name)", markup);
|
||||
Assert.Contains("OnOverrideChanged(attr.Name, e)", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodedRows_RoundTripThroughCodec_AsThePageDoes()
|
||||
{
|
||||
// Mirrors the load (Decode → rows) / save (Encode → JSON) cycle the page runs.
|
||||
var json = AttributeValueCodec.Encode(new List<string> { "10", "20", "30" });
|
||||
var decoded = AttributeValueCodec.Decode(json, DataType.List, DataType.Int32);
|
||||
var list = Assert.IsType<List<int>>(decoded);
|
||||
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||
|
||||
// The re-encoded form is stable, so a clean override round-trips losslessly.
|
||||
var roundTrip = AttributeValueCodec.Encode(decoded);
|
||||
Assert.Equal(json, roundTrip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedListElement_SurfacesFormatException_ForInlineError()
|
||||
{
|
||||
// The pre-submit guard catches this and shows it inline rather than crashing.
|
||||
var json = AttributeValueCodec.Encode(new List<string> { "1", "not-a-number" });
|
||||
Assert.Throws<FormatException>(
|
||||
() => AttributeValueCodec.Decode(json, DataType.List, DataType.Int32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Bunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
||||
|
||||
/// <summary>
|
||||
/// MV-13: the shared <c>AttributeListEditor</c> reveals an element-type select
|
||||
/// plus a repeatable list-value editor for structured List attributes. These are
|
||||
/// real bUnit rendering/interaction tests over the self-contained component, plus
|
||||
/// structural assertions pinning the TemplateEdit attribute-form wiring (the page
|
||||
/// itself is heavyweight to render — see <c>TemplateNativeAlarmSourceEditorTests</c>).
|
||||
/// </summary>
|
||||
public class AttributeListEditorTests : BunitContext
|
||||
{
|
||||
private static string TemplateEditMarkup
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Design", "TemplateEdit.razor"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_RendersElementTypeSelect_WithSixValidScalars()
|
||||
{
|
||||
var cut = Render<AttributeListEditor>(p => p
|
||||
.Add(x => x.ElementDataType, DataType.String)
|
||||
.Add(x => x.Rows, new List<string>()));
|
||||
|
||||
var select = cut.Find("select.form-select");
|
||||
var options = select.QuerySelectorAll("option");
|
||||
Assert.Equal(6, options.Length);
|
||||
var texts = options.Select(o => o.TextContent).ToArray();
|
||||
Assert.Contains("String", texts);
|
||||
Assert.Contains("Int32", texts);
|
||||
Assert.Contains("Float", texts);
|
||||
Assert.Contains("Double", texts);
|
||||
Assert.Contains("Boolean", texts);
|
||||
Assert.Contains("DateTime", texts);
|
||||
// List itself must never appear as an element type.
|
||||
Assert.DoesNotContain("List", texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowElementType_False_HidesTheSelect()
|
||||
{
|
||||
var cut = Render<AttributeListEditor>(p => p
|
||||
.Add(x => x.ShowElementType, false)
|
||||
.Add(x => x.Rows, new List<string>()));
|
||||
|
||||
Assert.Throws<ElementNotFoundException>(() => cut.Find("select.form-select"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_RendersOneInputPerRow()
|
||||
{
|
||||
var cut = Render<AttributeListEditor>(p => p
|
||||
.Add(x => x.ElementDataType, DataType.Int32)
|
||||
.Add(x => x.Rows, new List<string> { "1", "2", "3" }));
|
||||
|
||||
var inputs = cut.FindAll("input.form-control");
|
||||
Assert.Equal(3, inputs.Count);
|
||||
Assert.Equal("1", inputs[0].GetAttribute("value"));
|
||||
Assert.Equal("3", inputs[2].GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddElement_AppendsRow_AndRaisesRowsChanged()
|
||||
{
|
||||
var rows = new List<string> { "a" };
|
||||
List<string>? changed = null;
|
||||
var cut = Render<AttributeListEditor>(p => p
|
||||
.Add(x => x.Rows, rows)
|
||||
.Add(x => x.RowsChanged, r => changed = r));
|
||||
|
||||
cut.Find("button.btn-outline-secondary").Click();
|
||||
|
||||
Assert.NotNull(changed);
|
||||
Assert.Equal(2, changed!.Count);
|
||||
Assert.Equal("", changed[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveElement_DropsRow_AndRaisesRowsChanged()
|
||||
{
|
||||
var rows = new List<string> { "a", "b" };
|
||||
List<string>? changed = null;
|
||||
var cut = Render<AttributeListEditor>(p => p
|
||||
.Add(x => x.Rows, rows)
|
||||
.Add(x => x.RowsChanged, r => changed = r));
|
||||
|
||||
// First per-row Remove button.
|
||||
cut.FindAll("button.btn-outline-danger")[0].Click();
|
||||
|
||||
Assert.NotNull(changed);
|
||||
Assert.Single(changed!);
|
||||
Assert.Equal("b", changed![0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodedRows_RoundTripThroughCodec()
|
||||
{
|
||||
// Mirrors what TemplateEdit.SaveAttribute does on submit.
|
||||
var rows = new List<string> { "10", "20" };
|
||||
var json = AttributeValueCodec.Encode(rows);
|
||||
var decoded = AttributeValueCodec.Decode(json, DataType.List, DataType.Int32);
|
||||
var list = Assert.IsType<List<int>>(decoded);
|
||||
Assert.Equal(new[] { 10, 20 }, list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateEdit_RevealsListEditor_AndSendsElementType()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
// Conditional reveal on DataType.List.
|
||||
Assert.Contains("_attrDataType == DataType.List", markup);
|
||||
Assert.Contains("<AttributeListEditor", markup);
|
||||
Assert.Contains("@bind-ElementDataType=\"_attrElementDataType\"", markup);
|
||||
Assert.Contains("@bind-Rows=\"_attrListRows\"", markup);
|
||||
// Submit encodes rows to canonical JSON and passes the element type.
|
||||
Assert.Contains("AttributeValueCodec.Encode(_attrListRows)", markup);
|
||||
Assert.Contains("ElementDataType = elementType", markup);
|
||||
// Edit decodes the stored JSON value into rows.
|
||||
Assert.Contains("DecodeListRows(", markup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AttributeValueCodec"/> — the canonical, round-trippable
|
||||
/// codec used wherever an attribute value is stored or transmitted. Scalars must
|
||||
/// encode to their historical invariant-culture string; List attributes encode to
|
||||
/// a JSON array and decode back to a typed <c>List<T></c>.
|
||||
/// </summary>
|
||||
public class AttributeValueCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Encode_StringList_ProducesJsonArray() =>
|
||||
Assert.Equal("[\"WO-1\",\"WO-2\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_String_ReturnedAsIs() =>
|
||||
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_Double_IsInvariant()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("1.5", AttributeValueCodec.Encode(1.5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_Null_ReturnsNull() =>
|
||||
Assert.Null(AttributeValueCodec.Encode(null));
|
||||
|
||||
[Fact]
|
||||
public void Encode_EmptyList_IsBracketPair() =>
|
||||
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
|
||||
|
||||
[Fact]
|
||||
public void Encode_StringWithComma_IsEscaped() =>
|
||||
Assert.Equal("[\"ACME, Inc.\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_FloatList()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(new List<float> { 1.5f, 2.25f, -3.75f });
|
||||
var back = (IList<float>)AttributeValueCodec.Decode(json, DataType.List, DataType.Float)!;
|
||||
Assert.Equal(new[] { 1.5f, 2.25f, -3.75f }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_DoubleList_IsInvariant()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("[\"1.5\",\"2.5\"]",
|
||||
AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_Int32List()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(new List<int> { 1, 2, 3 });
|
||||
var back = (IList<int>)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!;
|
||||
Assert.Equal(new[] { 1, 2, 3 }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DoubleList_IsCultureInvariant()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
var json = AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 });
|
||||
var back = (IList<double>)AttributeValueCodec.Decode(json, DataType.List, DataType.Double)!;
|
||||
Assert.Equal(new[] { 1.5, 2.5 }, back);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_BoolList()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(new List<bool> { true, false, true });
|
||||
var back = (IList<bool>)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!;
|
||||
Assert.Equal(new[] { true, false, true }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DateTimeList_Iso8601()
|
||||
{
|
||||
var a = new DateTime(2026, 6, 15, 13, 45, 30, DateTimeKind.Utc);
|
||||
var b = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var json = AttributeValueCodec.Encode(new List<DateTime> { a, b });
|
||||
var back = (IList<DateTime>)AttributeValueCodec.Decode(json, DataType.List, DataType.DateTime)!;
|
||||
Assert.Equal(new[] { a, b }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_StringArray_ProducesListOfString()
|
||||
{
|
||||
var back = (IList<string>)AttributeValueCodec.Decode("[\"a\",\"b\"]", DataType.List, DataType.String)!;
|
||||
Assert.Equal(new[] { "a", "b" }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_Scalar_ReturnsString() =>
|
||||
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
|
||||
|
||||
[Fact]
|
||||
public void Decode_List_NullValue_ReturnsNull() =>
|
||||
Assert.Null(AttributeValueCodec.Decode(null, DataType.List, DataType.String));
|
||||
|
||||
[Fact]
|
||||
public void Decode_List_EmptyValue_ReturnsNull() =>
|
||||
Assert.Null(AttributeValueCodec.Decode("", DataType.List, DataType.String));
|
||||
|
||||
[Fact]
|
||||
public void Decode_MalformedJson_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
|
||||
|
||||
[Fact]
|
||||
public void Decode_UnparseableElement_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32));
|
||||
|
||||
[Fact]
|
||||
public void Decode_NullElement_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("[null]", DataType.List, DataType.String));
|
||||
|
||||
[Fact]
|
||||
public void Decode_List_WithoutElementType_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("[\"a\"]", DataType.List, null));
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataType.String, true)]
|
||||
[InlineData(DataType.Int32, true)]
|
||||
[InlineData(DataType.Float, true)]
|
||||
[InlineData(DataType.Double, true)]
|
||||
[InlineData(DataType.Boolean, true)]
|
||||
[InlineData(DataType.DateTime, true)]
|
||||
[InlineData(DataType.Binary, false)]
|
||||
[InlineData(DataType.List, false)]
|
||||
public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) =>
|
||||
Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t));
|
||||
}
|
||||
@@ -204,4 +204,37 @@ public class StreamRelayActorTests : TestKit
|
||||
Assert.True(channel.Reader.TryRead(out var evt));
|
||||
Assert.Equal("", evt.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListValue_EncodesAsJsonArray()
|
||||
{
|
||||
// List attributes must cross the wire as JSON, not as a comma-joined display string.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor("corr-list", channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new AttributeValueChanged("Inst", "Path", "Tags",
|
||||
new List<string> { "a", "b" }, "Good", ts));
|
||||
|
||||
Thread.Sleep(500);
|
||||
Assert.True(channel.Reader.TryRead(out var evt));
|
||||
Assert.Equal("[\"a\",\"b\"]", evt.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScalarStringValue_PassesThroughUnchanged()
|
||||
{
|
||||
// Scalar strings must not be double-encoded.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor("corr-scalar", channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", "x", "Good", ts));
|
||||
|
||||
Thread.Sleep(500);
|
||||
Assert.True(channel.Reader.TryRead(out var evt));
|
||||
Assert.Equal("x", evt.AttributeChanged.Value);
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// SCOPE: these tests cover ONLY the SDK-level building block the write path
|
||||
/// relies on — that <c>new Variant(collection)</c> wraps a CLR array / list as a
|
||||
/// typed array <see cref="Variant"/> (ValueRank = OneDimension) without throwing.
|
||||
/// They are NOT an end-to-end test of the runtime write flow: they feed a
|
||||
/// hand-built collection straight into <see cref="Variant"/>, bypassing the
|
||||
/// InstanceActor decode step that produces that collection.
|
||||
///
|
||||
/// The END-TO-END flow — a script's canonical JSON list string being DECODED to a
|
||||
/// typed <c>List<T></c> before the <c>WriteTagRequest</c> reaches the DCL
|
||||
/// (so OPC UA writes an array node, not a String scalar) — is covered by
|
||||
/// <c>InstanceActorTests.InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString</c>.
|
||||
/// The runtime hands <see cref="RealOpcUaClient.WriteValueAsync"/> a
|
||||
/// <c>List<T></c> (the codec's decode result), which the SDK wraps
|
||||
/// identically to a CLR array — see the <c>List<int></c> case below. A full
|
||||
/// device round-trip needs a live server and is covered by the live OPC UA smoke
|
||||
/// tests.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class RealOpcUaClientArrayWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public void Variant_wraps_int_list_as_array_without_throwing()
|
||||
{
|
||||
// The runtime actually hands WriteValueAsync a List<T> (the decode result),
|
||||
// not a raw T[]; assert the SDK wraps it as a typed array all the same.
|
||||
var value = new List<int> { 10, 20, 30 };
|
||||
|
||||
var ex = Record.Exception(() => new Variant(value));
|
||||
|
||||
Assert.Null(ex);
|
||||
var variant = new Variant(value);
|
||||
Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Variant_wraps_int_array_without_throwing()
|
||||
{
|
||||
var value = new[] { 10, 20, 30 };
|
||||
|
||||
var ex = Record.Exception(() => new Variant(value));
|
||||
|
||||
Assert.Null(ex);
|
||||
var variant = new Variant(value);
|
||||
Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Variant_wraps_double_array_without_throwing()
|
||||
{
|
||||
var value = new[] { 1.5, 2.5, 3.5 };
|
||||
|
||||
var ex = Record.Exception(() => new Variant(value));
|
||||
|
||||
Assert.Null(ex);
|
||||
var variant = new Variant(value);
|
||||
Assert.Equal(BuiltInType.Double, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Variant_wraps_string_array_without_throwing()
|
||||
{
|
||||
var value = new[] { "a", "b", "c" };
|
||||
|
||||
var ex = Record.Exception(() => new Variant(value));
|
||||
|
||||
Assert.Null(ex);
|
||||
var variant = new Variant(value);
|
||||
Assert.Equal(BuiltInType.String, variant.TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,202 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MV-10: ElementDataType accept + validate on attribute add/update
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddListAttribute_WithStringElementType_PersistsBothColumns()
|
||||
{
|
||||
// A template exists with no attributes; AddAttributeAsync will save the
|
||||
// entity built by the handler. Capture it to assert the persisted shape.
|
||||
var template = new Template("T1") { Id = 1 };
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
|
||||
TemplateAttribute? saved = null;
|
||||
_templateRepo
|
||||
.When(r => r.AddTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => saved = ci.Arg<TemplateAttribute>());
|
||||
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
|
||||
_services.AddScoped<TemplateService>();
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\",\"b\"]", null, null, false, "String"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(Commons.Types.Enums.DataType.List, saved!.DataType);
|
||||
Assert.Equal(Commons.Types.Enums.DataType.String, saved.ElementDataType);
|
||||
Assert.Equal("[\"a\",\"b\"]", saved.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddListAttribute_WithNoElementType_ReturnsManagementError()
|
||||
{
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, null),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("requires a valid element type", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddListAttribute_WithBinaryElementType_ReturnsManagementError()
|
||||
{
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Binary"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("requires a valid element type", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddScalarAttribute_WithElementType_ReturnsManagementError()
|
||||
{
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Count", "Int32", null, null, null, false, "String"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("only valid on List attributes", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddListAttribute_WithMalformedDefault_ReturnsManagementError()
|
||||
{
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\"", null, null, false, "String"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("invalid list value", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError()
|
||||
{
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Counts", "List", "[\"x\"]", null, null, false, "Int32"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("invalid list value", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateScalarAttribute_WithElementType_ReturnsManagementError()
|
||||
{
|
||||
// Update path runs the same pre-service validation: a scalar attribute
|
||||
// may not carry an element type, regardless of repository state.
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateTemplateAttributeCommand(5, "Count", "Int32", null, null, null, false, "String"),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("only valid on List attributes", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError()
|
||||
{
|
||||
// MV-10 review fix: a bogus DataType string used to throw ArgumentException
|
||||
// from Enum.Parse, which MapFault masks as a generic "An internal error
|
||||
// occurred" message. TryParse now raises a curated ManagementCommandException
|
||||
// whose message names the offending token.
|
||||
_services.AddScoped<TemplateService>();
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateAttributeCommand(1, "Whatever", "Nonsense", null, null, null, false, null),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Unrecognised data type", response.Error);
|
||||
Assert.Contains("Nonsense", response.Error);
|
||||
Assert.DoesNotContain("internal error", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
|
||||
{
|
||||
// MV-10 review fix: documents the fixed-field contract on update —
|
||||
// ValidateAttributeOverride(existing, proposed) runs for every update, so
|
||||
// a matching DataType/ElementDataType lets the Value change persist while
|
||||
// the fixed type columns are never copied from the proposed attribute.
|
||||
var template = new Template("T1") { Id = 1 };
|
||||
var existing = new TemplateAttribute("Count")
|
||||
{
|
||||
Id = 5, TemplateId = 1, DataType = Commons.Types.Enums.DataType.Int32,
|
||||
ElementDataType = null, Value = "0"
|
||||
};
|
||||
_templateRepo.GetTemplateAttributeByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { template });
|
||||
|
||||
TemplateAttribute? updated = null;
|
||||
_templateRepo
|
||||
.When(r => r.UpdateTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => updated = ci.Arg<TemplateAttribute>());
|
||||
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
|
||||
_services.AddScoped<TemplateService>();
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateTemplateAttributeCommand(5, "Count", "Int32", "42", null, null, false, null),
|
||||
"Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("42", updated!.Value); // Value updated
|
||||
Assert.Equal(Commons.Types.Enums.DataType.Int32, updated.DataType); // fixed
|
||||
Assert.Null(updated.ElementDataType); // fixed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -444,4 +445,575 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
}
|
||||
|
||||
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
|
||||
|
||||
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
|
||||
{
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
// On startup the actor subscribes its data-sourced tags through the DCL.
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
|
||||
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
|
||||
/// coerced into a typed <c>List<int></c> whose elements match the
|
||||
/// attribute's ElementDataType. The stored value must be a real list — not a
|
||||
/// JSON string — so scripts read a typed collection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_CoercesArrayToTypedList()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Setpoints";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-List",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoints", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-List", config, dcl);
|
||||
|
||||
// OPC UA delivers an array value (CLR array) for the List-typed tag.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 10, 20, 30 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-list", "Pump-List", "Setpoints", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: array elements coming in as a different CLR type (here, strings that
|
||||
/// are valid integers) must still coerce to the declared element type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_CoercesElementTypes()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Levels";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListCoerce",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Levels", Value = null,
|
||||
DataType = "List", ElementDataType = "Double",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListCoerce", config, dcl);
|
||||
|
||||
// Elements arrive as ints/strings but the attribute is List<double>.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "2.5", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-coerce", "Pump-ListCoerce", "Levels", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
var list = Assert.IsType<List<double>>(response.Value);
|
||||
Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: an element that cannot be coerced to the declared element type must
|
||||
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
|
||||
/// stays alive and continues to answer queries).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_ElementMismatch_SetsBadQuality_ActorAlive()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Bad";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListBad",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListBad", config, dcl);
|
||||
Watch(actor);
|
||||
|
||||
// "not-a-number" cannot be coerced to int → Bad quality, no crash.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "not-a-number", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bad", "Pump-ListBad", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
|
||||
// The actor must still be alive (no crash / restart) and serving.
|
||||
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8 design contract: a failed coercion keeps the PRIOR value. A good
|
||||
/// array is delivered first and stored as a typed list; a subsequent array
|
||||
/// with a non-coercible element must NOT overwrite that value — the stored
|
||||
/// value stays the prior list while the quality flips to <c>Bad</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListAttribute_BadCoercion_PreservesPriorValue()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Keep";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListKeep",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListKeep", config, dcl);
|
||||
|
||||
// (1) A good array establishes the prior value.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 1, 2, 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// (2) A second array with a non-coercible element must not overwrite it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 4, "not-a-number", 6 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// (3) The stored value is still the prior list; quality is Bad.
|
||||
actor.Tell(new GetAttributeRequest("corr-keep", "Pump-ListKeep", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 1, 2, 3 }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
|
||||
/// pass-through behaviour — a scalar value is stored unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedScalarAttribute_UnchangedByListPath()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Speed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-Scalar",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-Scalar", config, dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-scalar", "Pump-Scalar", "Speed", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
Assert.Equal(1450, response.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV (C1 fix): a WRITE to a data-sourced <c>DataType.List</c> attribute must
|
||||
/// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the
|
||||
/// canonical JSON string the script layer produced. The script path encodes
|
||||
/// <c>List<int></c> to <c>"[10,20,30]"</c>; HandleSetDataAttribute must
|
||||
/// decode that back to a typed <c>List<int></c> before building the
|
||||
/// WriteTagRequest. We assert the captured WriteTagRequest.Value is the typed
|
||||
/// list {10,20,30} — never the string "[10,20,30]".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Setpoints";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListWrite",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoints", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListWrite", config, dcl);
|
||||
|
||||
// Script-style write: ScopeAccessors (AttributeValueCodec.Encode) has
|
||||
// already encoded the script's List<int> to the canonical JSON array string,
|
||||
// which is an array of element STRINGS (not raw JSON numbers).
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-write", "Pump-ListWrite", "Setpoints", "[\"10\",\"20\",\"30\"]", DateTimeOffset.UtcNow));
|
||||
|
||||
// The DCL must receive a WriteTagRequest carrying a TYPED collection.
|
||||
var write = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("PLC", write.ConnectionName);
|
||||
Assert.Equal(tag, write.TagPath);
|
||||
Assert.IsNotType<string>(write.Value);
|
||||
var list = Assert.IsType<List<int>>(write.Value);
|
||||
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||
|
||||
// Complete the Ask so the actor replies success to the caller.
|
||||
dcl.Reply(new WriteTagResponse("corr-write", true, null, DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV (C1 fix): a malformed value written to a data-sourced List attribute
|
||||
/// must be REJECTED before reaching the DCL — Success=false and NO
|
||||
/// WriteTagRequest is forwarded (mirrors the static-path malformed rejection).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedListWrite_Malformed_Rejected_NoDclWrite()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.Bad";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ListWriteBad",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Setpoints", Value = null,
|
||||
DataType = "List", ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-ListWriteBad", config, dcl);
|
||||
|
||||
// Malformed JSON (unterminated array, non-int element) → reject the write.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-bad-write", "Pump-ListWriteBad", "Setpoints", "[\"a\"", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(response.Success);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
|
||||
// No write must reach the DCL.
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
|
||||
{
|
||||
// The actor is Watch()ed; assert no Terminated arrives in the window.
|
||||
// (Liveness is also proven by the preceding successful GetAttributeResponse.)
|
||||
ExpectNoMsg(within);
|
||||
}
|
||||
|
||||
// ── MV-7: static (authored) List attribute decode ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a STATIC List attribute carries its default as the canonical JSON
|
||||
/// array string. On load the actor must decode it to a typed list so a
|
||||
/// script reading the attribute receives a real collection, not the raw
|
||||
/// JSON string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_StaticListAttribute_LoadsAsTypedList()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-StaticList",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-StaticList", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-sl", "Pump-StaticList", "Labels", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
var list = Assert.IsType<List<string>>(response.Value);
|
||||
Assert.Equal(new[] { "a", "b" }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical
|
||||
/// JSON value into a typed list for in-memory reads, but the PERSISTED form
|
||||
/// (SQLite static override) must remain the canonical JSON string — never a
|
||||
/// CLR-list .ToString().
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-SetList",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-SetList", config);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-set-list", "Pump-SetList", "Labels", "[\"x\",\"y\"]", DateTimeOffset.UtcNow));
|
||||
var setResponse = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(setResponse.Success);
|
||||
|
||||
// In-memory read returns a typed list.
|
||||
actor.Tell(new GetAttributeRequest("corr-get-list", "Pump-SetList", "Labels", DateTimeOffset.UtcNow));
|
||||
var getResponse = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(getResponse.Found);
|
||||
var list = Assert.IsType<List<string>>(getResponse.Value);
|
||||
Assert.Equal(new[] { "x", "y" }, list);
|
||||
|
||||
// The persisted form is the canonical JSON string, NOT a CLR-list .ToString().
|
||||
await Task.Delay(500);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-SetList");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[\"x\",\"y\"]", overrides["Labels"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a persisted static override for a List attribute is a canonical JSON
|
||||
/// string in SQLite; on load it must be decoded to a typed list, the same as
|
||||
/// the config default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_StaticListOverride_LoadsAsTypedList()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-OverrideList", "Labels", "[\"p\",\"q\"]");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-OverrideList",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-OverrideList", config);
|
||||
|
||||
// Wait for the async override load (PipeTo) to apply.
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-ol", "Pump-OverrideList", "Labels", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
var list = Assert.IsType<List<string>>(response.Value);
|
||||
Assert.Equal(new[] { "p", "q" }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a malformed stored List value must NOT crash the actor — it loads
|
||||
/// with quality Bad and the actor stays alive and answering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_StaticListAttribute_Malformed_LoadsBadQuality_ActorAlive()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadList",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\"", // truncated JSON
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-BadList", config);
|
||||
Watch(actor);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bl", "Pump-BadList", "Labels", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
Assert.Null(response.Value);
|
||||
|
||||
// The actor must still be alive (no crash / restart during construction).
|
||||
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 guard: a scalar static attribute is unaffected by the List decode
|
||||
/// path — it still returns its raw string value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_StaticScalarAttribute_UnaffectedByListDecode()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-StaticScalar",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-StaticScalar", config);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-ss", "Pump-StaticScalar", "Label", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
Assert.Equal("Main Pump", response.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as
|
||||
/// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply
|
||||
/// Success=false with a clear error and persist NOTHING. The script path always
|
||||
/// pre-encodes valid JSON, but the Inbound API / direct-command path can submit
|
||||
/// an arbitrary value, so a malformed value must not silently null the in-memory
|
||||
/// value, publish "Good" quality, and durably persist a poison override.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadSet",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-BadSet", config);
|
||||
|
||||
// Submit a malformed list value (truncated JSON).
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-bad-set", "Pump-BadSet", "Labels", "[\"a\"", DateTimeOffset.UtcNow));
|
||||
var setResponse = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.False(setResponse.Success);
|
||||
Assert.False(string.IsNullOrWhiteSpace(setResponse.ErrorMessage));
|
||||
|
||||
// The poison value must NOT have been persisted as a static override.
|
||||
await Task.Delay(500);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadSet");
|
||||
Assert.Empty(overrides);
|
||||
|
||||
// A subsequent read returns the untouched config default — not the poison value.
|
||||
actor.Tell(new GetAttributeRequest("corr-bad-get", "Pump-BadSet", "Labels", DateTimeOffset.UtcNow));
|
||||
var getResponse = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(getResponse.Found);
|
||||
var list = Assert.IsType<List<string>>(getResponse.Value);
|
||||
Assert.Equal(new[] { "a", "b" }, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list
|
||||
/// and must be accepted (NOT mistaken for a malformed value, which also decodes
|
||||
/// to null). This pins the boundary between the "clearing/empty" and "poison"
|
||||
/// cases that both surface as a null decode result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-EmptySet",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
|
||||
DataType = "List", ElementDataType = "String"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-EmptySet", config);
|
||||
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-empty-set", "Pump-EmptySet", "Labels", "[]", DateTimeOffset.UtcNow));
|
||||
var setResponse = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(setResponse.Success);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-empty-get", "Pump-EmptySet", "Labels", DateTimeOffset.UtcNow));
|
||||
var getResponse = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(getResponse.Found);
|
||||
var list = Assert.IsType<List<string>>(getResponse.Value);
|
||||
Assert.Empty(list);
|
||||
|
||||
// The canonical JSON "[]" is persisted unchanged.
|
||||
await Task.Delay(500);
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-EmptySet");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[]", overrides["Labels"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
@@ -84,4 +85,54 @@ public class ScopeAccessorTests
|
||||
var temp = children["TempSensor"];
|
||||
Assert.Equal("Motor.TempSensor", temp.Path);
|
||||
}
|
||||
|
||||
// --- AttributeAccessor encoding contract ----------------------------------
|
||||
//
|
||||
// AttributeAccessor.this[key].set and SetAsync both route through
|
||||
// ScriptRuntimeContext.SetAttribute(name, encodedString), which requires
|
||||
// a live Akka IActorRef; ScriptRuntimeContext has no virtual members and
|
||||
// its constructor cannot be satisfied without a real ActorSystem, so a
|
||||
// full-round-trip unit test through the accessor+context is not viable
|
||||
// without a heavy Akka harness.
|
||||
//
|
||||
// Instead we test the encoding decision directly: AttributeAccessor is now
|
||||
// documented to delegate value serialisation to AttributeValueCodec.Encode.
|
||||
// These tests verify that contract at the codec level, which is exactly what
|
||||
// the fix makes the accessor invoke.
|
||||
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_List_ProducesJsonArray()
|
||||
{
|
||||
// A List<string> must encode to a JSON array, not the garbage
|
||||
// "System.Collections.Generic.List`1[System.String]" that .ToString() produced.
|
||||
var list = new List<string> { "a", "b" };
|
||||
var encoded = AttributeValueCodec.Encode(list);
|
||||
Assert.Equal("[\"a\",\"b\"]", encoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_Scalar_PassesThrough()
|
||||
{
|
||||
// A plain string scalar must be returned unchanged (byte-identical to
|
||||
// the historical value?.ToString() path for strings).
|
||||
var encoded = AttributeValueCodec.Encode("x");
|
||||
Assert.Equal("x", encoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_Null_ReturnsNull()
|
||||
{
|
||||
// AttributeAccessor coalesces null → "" at the call site,
|
||||
// but the codec itself must return null for null input.
|
||||
Assert.Null(AttributeValueCodec.Encode(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeValueCodec_Encode_IntList_ProducesJsonArray()
|
||||
{
|
||||
// Integer list elements encode via InvariantCulture IFormattable.
|
||||
var list = new List<int> { 1, 2, 3 };
|
||||
var encoded = AttributeValueCodec.Encode(list);
|
||||
Assert.Equal("[\"1\",\"2\",\"3\"]", encoded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,4 +788,67 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("ns=2;s=Tank07", result.Value.NativeAlarmSources[0].SourceReference);
|
||||
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
|
||||
}
|
||||
|
||||
// ── MV-4: ElementDataType carried through flatten/override ────────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ListAttribute_ElementDataTypeCarriedToResolvedAttribute()
|
||||
{
|
||||
// A template with a List attribute whose ElementDataType is String.
|
||||
// The flattened result must carry DataType == "List" and ElementDataType == "String".
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String,
|
||||
Value = null
|
||||
});
|
||||
|
||||
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);
|
||||
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Tags");
|
||||
Assert.Equal("List", attr.DataType);
|
||||
Assert.Equal("String", attr.ElementDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ListAttributeWithInstanceOverride_ElementDataTypePreservedThroughOverride()
|
||||
{
|
||||
// An instance override replaces the VALUE of a List attribute.
|
||||
// The ElementDataType must survive the override path unchanged.
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Attributes.Add(new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String,
|
||||
Value = "[\"a\",\"b\"]"
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Tags")
|
||||
{
|
||||
OverrideValue = "[\"x\",\"y\",\"z\"]"
|
||||
});
|
||||
|
||||
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 == "Tags");
|
||||
Assert.Equal("[\"x\",\"y\",\"z\"]", attr.Value); // override applied
|
||||
Assert.Equal("Override", attr.Source); // came from override path
|
||||
Assert.Equal("List", attr.DataType); // type unchanged
|
||||
Assert.Equal("String", attr.ElementDataType); // element type preserved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,64 @@ public class LockEnforcerTests
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_ElementDataTypeChanged_ReturnsError()
|
||||
{
|
||||
// MV-10 review fix: ElementDataType is the element scalar type of a List
|
||||
// attribute and is fixed by the defining level, exactly like DataType.
|
||||
// TemplateService.UpdateAttributeAsync never copies it onto the persisted
|
||||
// row, so a mismatch must be rejected before the Value (validated against
|
||||
// the real element type) is persisted against the wrong type.
|
||||
var original = new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, IsLocked = false
|
||||
};
|
||||
var proposed = new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.String, IsLocked = false // changed!
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("ElementDataType", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_ElementDataTypeMatches_ReturnsNull()
|
||||
{
|
||||
var original = new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, IsLocked = false, Value = "[1]"
|
||||
};
|
||||
var proposed = new TemplateAttribute("Tags")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, IsLocked = false, Value = "[1,2]"
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAttributeOverride_ElementDataTypeBothNull_ReturnsNull()
|
||||
{
|
||||
// Scalar attributes carry no element type on either side — not a change.
|
||||
var original = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, ElementDataType = null, IsLocked = false, Value = "0"
|
||||
};
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, ElementDataType = null, IsLocked = false, Value = "100"
|
||||
};
|
||||
|
||||
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAlarmOverride_LockedAlarm_ReturnsError()
|
||||
{
|
||||
|
||||
+213
@@ -1056,4 +1056,217 @@ public class SemanticValidatorTests
|
||||
var result = _sut.Validate(config);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
|
||||
}
|
||||
|
||||
// ── MV-5 List attribute semantics ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_ValidElementTypeAndDefault_NoError()
|
||||
{
|
||||
// List + String element + a well-formed JSON-array default — fully valid.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Tags",
|
||||
DataType = "List",
|
||||
ElementDataType = "String",
|
||||
Value = "[\"a\",\"b\"]"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_NullElementType_ReturnsError()
|
||||
{
|
||||
// List with no element type — rule 1 violation (cardinality).
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = null }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.MissingMetadata &&
|
||||
e.EntityName == "Tags" &&
|
||||
e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ScalarAttribute_WithElementType_ReturnsError()
|
||||
{
|
||||
// A non-List attribute must not carry an element type — rule 1 violation.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Status", DataType = "String", ElementDataType = "String" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.MissingMetadata &&
|
||||
e.EntityName == "Status" &&
|
||||
e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_NonScalarElementType_ReturnsError()
|
||||
{
|
||||
// Binary and List are not valid List element scalars — rule 1 violation.
|
||||
foreach (var bad in new[] { "Binary", "List" })
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = bad }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.MissingMetadata &&
|
||||
e.EntityName == "Tags");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_MalformedJsonDefault_ReturnsError()
|
||||
{
|
||||
// Unterminated JSON array default — rule 2 (default parseability) violation.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Tags",
|
||||
DataType = "List",
|
||||
ElementDataType = "String",
|
||||
Value = "[\"a\""
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.MissingMetadata &&
|
||||
e.EntityName == "Tags" &&
|
||||
e.Message.Contains("default", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_DefaultElementNotParseable_ReturnsError()
|
||||
{
|
||||
// Element type Int32 but the default carries a non-integer element — rule 2 violation.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts",
|
||||
DataType = "List",
|
||||
ElementDataType = "Int32",
|
||||
Value = "[\"x\"]"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.MissingMetadata &&
|
||||
e.EntityName == "Counts" &&
|
||||
e.Message.Contains("Int32"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_EmptyDefault_NoDefaultError()
|
||||
{
|
||||
// No authored default → rule 2 is inert; only the (satisfied) cardinality rule applies.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "String", Value = "" }
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_AsRangeViolationOperand_ReturnsError()
|
||||
{
|
||||
// Rule 3 confirmation: a List attribute is non-numeric, so a RangeViolation
|
||||
// trigger over it is rejected by the existing NumericDataTypes guard.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Int32" }
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "BadAlarm",
|
||||
TriggerType = "RangeViolation",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Tags\"}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.TriggerOperandType &&
|
||||
e.Message.Contains("non-numeric"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ListAttribute_AsHiLoOperand_ReturnsError()
|
||||
{
|
||||
// Rule 3 confirmation for HiLo.
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Double" }
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "BadHiLo",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Tags\",\"hi\":80}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _sut.Validate(config);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Category == ValidationCategory.TriggerOperandType &&
|
||||
e.Message.Contains("non-numeric"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,4 +223,71 @@ public sealed class EntitySerializerTests
|
||||
var sys = Assert.Single(aggregate.ExternalSystems);
|
||||
Assert.Null(sys.AuthConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_List_attribute_preserves_ElementDataType()
|
||||
{
|
||||
// A template with a DataType.List attribute whose element type is String.
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("Tags")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String,
|
||||
Value = "[\"a\",\"b\"]",
|
||||
IsLocked = false,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
// Export side: DTO must carry ElementDataType.
|
||||
var dtoTemplate = Assert.Single(dto.Templates);
|
||||
var dtoAttr = Assert.Single(dtoTemplate.Attributes);
|
||||
Assert.Equal(DataType.List, dtoAttr.DataType);
|
||||
Assert.Equal(DataType.String, dtoAttr.ElementDataType);
|
||||
|
||||
// Import side: entity reconstructed from DTO preserves the value.
|
||||
var roundTripped = sut.FromBundleContent(dto);
|
||||
var rtTemplate = Assert.Single(roundTripped.Templates);
|
||||
var rtAttr = Assert.Single(rtTemplate.Attributes);
|
||||
Assert.Equal(DataType.List, rtAttr.DataType);
|
||||
Assert.Equal(DataType.String, rtAttr.ElementDataType);
|
||||
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
|
||||
{
|
||||
// Backward-compat: an old bundle DTO with null ElementDataType must not throw
|
||||
// and must produce a scalar attribute with null ElementDataType.
|
||||
var template = new Template("Sensor") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("Pressure")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
DataType = DataType.Double,
|
||||
ElementDataType = null,
|
||||
Value = "42.0",
|
||||
IsLocked = false,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
var dtoTemplate = Assert.Single(dto.Templates);
|
||||
var dtoAttr = Assert.Single(dtoTemplate.Attributes);
|
||||
Assert.Equal(DataType.Double, dtoAttr.DataType);
|
||||
Assert.Null(dtoAttr.ElementDataType);
|
||||
|
||||
var roundTripped = sut.FromBundleContent(dto);
|
||||
var rtAttr = Assert.Single(Assert.Single(roundTripped.Templates).Attributes);
|
||||
Assert.Equal(DataType.Double, rtAttr.DataType);
|
||||
Assert.Null(rtAttr.ElementDataType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user