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:
Joseph Doherty
2026-06-16 16:51:36 -04:00
45 changed files with 5720 additions and 77 deletions
@@ -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) | smallmed |
| 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) | smallmed |
| Validation | `SemanticValidator.cs:18-21,130-193`, `ValidationService.cs` | smallmed |
| Streaming | `StreamRelayActor.cs:48` | small |
| UI | `TemplateEdit.razor`, `InstanceConfigure.razor` | **med (largest)** |
| CLI + mgmt | `TemplateCommands.cs`, `ManagementActor.cs:1441-1461` | smallmed |
| 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&lt;T&gt;</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"
}
+1 -1
View File
@@ -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: 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). - **`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. - **`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. - **`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 templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Attribute name", 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 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 descOption = new Option<string?>("--description") { Description = "Description" };
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" }; 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" }; var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false; lockedOption.DefaultValueFactory = _ => false;
@@ -151,28 +152,39 @@ public static class TemplateCommands
addCmd.Add(valueOption); addCmd.Add(valueOption);
addCmd.Add(descOption); addCmd.Add(descOption);
addCmd.Add(sourceOption); addCmd.Add(sourceOption);
addCmd.Add(elementTypeOption);
addCmd.Add(lockedOption); addCmd.Add(lockedOption);
addCmd.SetAction(async (ParseResult result) => 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( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAttributeCommand( BuildAddAttributeCommand(
result.GetValue(templateIdOption), result.GetValue(templateIdOption),
result.GetValue(nameOption)!, result.GetValue(nameOption)!,
result.GetValue(dataTypeOption)!, dataType,
result.GetValue(valueOption), result.GetValue(valueOption),
result.GetValue(descOption), result.GetValue(descOption),
result.GetValue(sourceOption), result.GetValue(sourceOption),
result.GetValue(lockedOption))); result.GetValue(lockedOption),
elementType));
}); });
group.Add(addCmd); group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true }; var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", 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 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 updateDescOption = new Option<string?>("--description") { Description = "Description" };
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" }; 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" }; var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false; updateLockedOption.DefaultValueFactory = _ => false;
@@ -183,19 +195,29 @@ public static class TemplateCommands
updateCmd.Add(updateValueOption); updateCmd.Add(updateValueOption);
updateCmd.Add(updateDescOption); updateCmd.Add(updateDescOption);
updateCmd.Add(updateSourceOption); updateCmd.Add(updateSourceOption);
updateCmd.Add(updateElementTypeOption);
updateCmd.Add(updateLockedOption); updateCmd.Add(updateLockedOption);
updateCmd.SetAction(async (ParseResult result) => 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( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateAttributeCommand( BuildUpdateAttributeCommand(
result.GetValue(updateIdOption), result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!, result.GetValue(updateNameOption)!,
result.GetValue(updateDataTypeOption)!, dataType,
result.GetValue(updateValueOption), result.GetValue(updateValueOption),
result.GetValue(updateDescOption), result.GetValue(updateDescOption),
result.GetValue(updateSourceOption), result.GetValue(updateSourceOption),
result.GetValue(updateLockedOption))); result.GetValue(updateLockedOption),
elementType));
}); });
group.Add(updateCmd); group.Add(updateCmd);
@@ -213,6 +235,82 @@ public static class TemplateCommands
return group; 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) 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" }; var group = new Command("alarm") { Description = "Manage template alarms" };
+31 -14
View File
@@ -164,45 +164,62 @@ scadabridge --url <url> template validate --id <int>
Add an attribute to a template. Add an attribute to a template.
```sh ```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 | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--template-id` | yes | Template ID | | `--template-id` | yes | Template ID |
| `--name` | yes | Attribute name | | `--name` | yes | Attribute name |
| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) | | `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--default-value` | no | Default value | | `--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 |
| `--tag-path` | no | Data connection tag path | | `--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` #### `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 ```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 | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--template-id` | yes | Template ID | | `--id` | yes | Attribute ID |
| `--name` | yes | Attribute name to update | | `--name` | yes | Attribute name |
| `--data-type` | no | Updated data type | | `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--default-value` | no | Updated default value | | `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) |
| `--tag-path` | no | Updated tag path | | `--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` #### `template attribute delete`
Remove an attribute from a template. Remove an attribute from a template.
```sh ```sh
scadabridge --url <url> template attribute delete --template-id <int> --name <string> scadabridge --url <url> template attribute delete --id <int>
``` ```
| Option | Required | Description | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--template-id` | yes | Template ID | | `--id` | yes | Attribute ID |
| `--name` | yes | Attribute name to delete |
#### `template alarm add` #### `template alarm add`
@@ -5,6 +5,7 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @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.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@@ -184,26 +185,63 @@
} }
else 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"> <thead class="table-light">
<tr> <tr>
<th>Attribute</th> <th>Attribute</th>
<th>Type</th> <th>Type</th>
<th>Template Value</th> <th>Template Value</th>
<th style="width: 280px;">Override Value</th> <th style="width: 320px;">Override Value</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var attr in _overrideAttrs) @foreach (var attr in _overrideAttrs)
{ {
<tr> <tr data-test="override-row-@attr.Name">
<td class="small">@attr.Name</td> <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 class="small text-muted">@(attr.Value ?? "—")</td>
<td> <td>
<input type="text" class="form-control form-control-sm" @if (attr.DataType == DataType.List)
value="@GetOverrideValue(attr.Name)" {
@onchange="(e) => OnOverrideChanged(attr.Name, e)" /> @* 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> </td>
</tr> </tr>
} }
@@ -507,6 +545,17 @@
// Overrides // Overrides
private List<TemplateAttribute> _overrideAttrs = new(); private List<TemplateAttribute> _overrideAttrs = new();
private Dictionary<string, string?> _overrideValues = 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 // Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time). // is the only mutation path (one alarm at a time).
@@ -595,7 +644,23 @@
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id); var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
foreach (var o in existingOverrides) foreach (var o in existingOverrides)
{
_overrideValues[o.AttributeName] = o.OverrideValue; _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 // Alarm overrides — load all non-locked template alarms and
// existing override rows. Pre-seed the dirty maps from existing // existing override rows. Pre-seed the dirty maps from existing
@@ -805,15 +870,123 @@
else _overrideValues[attrName] = val; 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() private async Task SaveOverrides()
{ {
_saving = true; _saving = true;
try try
{ {
_overrideErrors.Clear();
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
foreach (var (attrName, value) in _overrideValues)
await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); // Build the set of override values to persist. Scalars come straight
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s)."); // 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) catch (Exception ex)
{ {
@@ -4,6 +4,7 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @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.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine @using ZB.MOM.WW.ScadaBridge.TemplateEngine
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@@ -80,6 +81,10 @@
private string _attrName = string.Empty; private string _attrName = string.Empty;
private string? _attrValue; private string? _attrValue;
private DataType _attrDataType; 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 bool _attrIsLocked;
private string? _attrDataSourceRef; private string? _attrDataSourceRef;
private string? _attrFormError; private string? _attrFormError;
@@ -553,17 +558,34 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Data Type</label> <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>()) @foreach (var dt in Enum.GetValues<DataType>())
{ {
<option value="@dt">@dt</option> <option value="@dt">@dt</option>
} }
</select> </select>
@if (editing)
{
<div class="form-text">Data type is fixed once the attribute is created.</div>
}
</div> </div>
<div class="col-12"> @if (_attrDataType == DataType.List)
<label class="form-label">Value</label> {
<input type="text" class="form-control" @bind="_attrValue" /> <div class="col-12">
</div> @* 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"> <div class="col-12">
<label class="form-label">Data Source Ref</label> <label class="form-label">Data Source Ref</label>
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" /> <input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
@@ -1535,6 +1557,8 @@
_attrName = string.Empty; _attrName = string.Empty;
_attrValue = null; _attrValue = null;
_attrDataType = default; _attrDataType = default;
_attrElementDataType = DataType.String;
_attrListRows = new();
_attrIsLocked = false; _attrIsLocked = false;
_attrDataSourceRef = null; _attrDataSourceRef = null;
} }
@@ -1547,6 +1571,8 @@
_attrName = attr.Name; _attrName = attr.Name;
_attrValue = attr.Value; _attrValue = attr.Value;
_attrDataType = attr.DataType; _attrDataType = attr.DataType;
_attrElementDataType = attr.ElementDataType ?? DataType.String;
_attrListRows = DecodeListRows(attr.Value, attr.ElementDataType);
_attrIsLocked = attr.IsLocked; _attrIsLocked = attr.IsLocked;
_attrDataSourceRef = attr.DataSourceReference; _attrDataSourceRef = attr.DataSourceReference;
} }
@@ -1558,12 +1584,72 @@
_attrFormError = null; _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() private async Task SaveAttribute()
{ {
if (_selectedTemplate == null) return; if (_selectedTemplate == null) return;
_attrFormError = null; _attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; } 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(); var user = await GetCurrentUserAsync();
if (_editAttrId is int id) if (_editAttrId is int id)
@@ -1573,7 +1659,8 @@
var proposed = new TemplateAttribute(existing.Name) var proposed = new TemplateAttribute(existing.Name)
{ {
DataType = _attrDataType, DataType = _attrDataType,
Value = _attrValue?.Trim(), ElementDataType = elementType,
Value = attrValue,
IsLocked = _attrIsLocked, IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim(), DataSourceReference = _attrDataSourceRef?.Trim(),
Description = existing.Description, Description = existing.Description,
@@ -1598,7 +1685,8 @@
var attr = new TemplateAttribute(_attrName.Trim()) var attr = new TemplateAttribute(_attrName.Trim())
{ {
DataType = _attrDataType, DataType = _attrDataType,
Value = _attrValue?.Trim(), ElementDataType = elementType,
Value = attrValue,
IsLocked = _attrIsLocked, IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim() 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; namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
public class InstanceAttributeOverride public class InstanceAttributeOverride
@@ -10,6 +12,13 @@ public class InstanceAttributeOverride
public string AttributeName { get; set; } public string AttributeName { get; set; }
/// <summary>Gets or sets the override value, or <c>null</c> to clear a previous override.</summary> /// <summary>Gets or sets the override value, or <c>null</c> to clear a previous override.</summary>
public string? OverrideValue { get; set; } 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> /// <summary>Initializes a new <see cref="InstanceAttributeOverride"/> for the given attribute name.</summary>
/// <param name="attributeName">The name of the attribute to override.</param> /// <param name="attributeName">The name of the attribute to override.</param>
@@ -25,6 +25,13 @@ public class TemplateAttribute
/// </summary> /// </summary>
public DataType DataType { get; set; } public DataType DataType { get; set; }
/// <summary> /// <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. /// Gets or sets a value indicating whether the attribute is locked from override.
/// </summary> /// </summary>
public bool IsLocked { get; set; } public bool IsLocked { get; set; }
@@ -8,8 +8,8 @@ public record DeleteTemplateCommand(int TemplateId);
public record ValidateTemplateCommand(int TemplateId); public record ValidateTemplateCommand(int TemplateId);
// Template member operations // Template member operations
public record AddTemplateAttributeCommand(int TemplateId, 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); 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 DeleteTemplateAttributeCommand(int AttributeId);
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked); 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); 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&lt;T&gt;</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, Double,
String, String,
DateTime, DateTime,
Binary Binary,
List
} }
@@ -66,6 +66,8 @@ public sealed record ResolvedAttribute
public string? Value { get; init; } public string? Value { get; init; }
/// <summary>Gets the data type name.</summary> /// <summary>Gets the data type name.</summary>
public string DataType { get; init; } = string.Empty; 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> /// <summary>Gets whether the attribute is locked.</summary>
public bool IsLocked { get; init; } public bool IsLocked { get; init; }
/// <summary>Gets the attribute description.</summary> /// <summary>Gets the attribute description.</summary>
@@ -45,7 +45,7 @@ public class StreamRelayActor : ReceiveActor
InstanceUniqueName = msg.InstanceUniqueName, InstanceUniqueName = msg.InstanceUniqueName,
AttributePath = msg.AttributePath, AttributePath = msg.AttributePath,
AttributeName = msg.AttributeName, AttributeName = msg.AttributeName,
Value = ValueFormatter.FormatDisplayValue(msg.Value), Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,
Quality = MapQuality(msg.Quality), Quality = MapQuality(msg.Quality),
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp) Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp)
} }
@@ -99,8 +99,12 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<I
.IsRequired() .IsRequired()
.HasMaxLength(200); .HasMaxLength(200);
builder.Property(o => o.OverrideValue) // nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars.
.HasMaxLength(4000); builder.Property(o => o.OverrideValue);
builder.Property(o => o.ElementDataType)
.HasConversion<string>()
.HasMaxLength(50);
builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique(); builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique();
} }
@@ -108,8 +108,8 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateA
.IsRequired() .IsRequired()
.HasMaxLength(200); .HasMaxLength(200);
builder.Property(a => a.Value) // nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars.
.HasMaxLength(4000); builder.Property(a => a.Value);
builder.Property(a => a.Description) builder.Property(a => a.Description)
.HasMaxLength(2000); .HasMaxLength(2000);
@@ -121,6 +121,10 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateA
.HasConversion<string>() .HasConversion<string>()
.HasMaxLength(50); .HasMaxLength(50);
builder.Property(a => a.ElementDataType)
.HasConversion<string>()
.HasMaxLength(50);
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique(); builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
} }
} }
@@ -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;");
}
}
}
@@ -563,12 +563,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("nvarchar(200)"); .HasColumnType("nvarchar(200)");
b.Property<string>("ElementDataType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("InstanceId") b.Property<int>("InstanceId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("OverrideValue") b.Property<string>("OverrideValue")
.HasMaxLength(4000) .HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(4000)");
b.HasKey("Id"); b.HasKey("Id");
@@ -1164,6 +1167,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("nvarchar(2000)"); .HasColumnType("nvarchar(2000)");
b.Property<string>("ElementDataType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<bool>("IsInherited") b.Property<bool>("IsInherited")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -1182,8 +1189,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("Value") b.Property<string>("Value")
.HasMaxLength(4000) .HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(4000)");
b.HasKey("Id"); b.HasKey("Id");
@@ -524,6 +524,7 @@ public class ManagementActor : ReceiveActor
CanonicalName = a.Name, CanonicalName = a.Name,
Value = a.Value, Value = a.Value,
DataType = a.DataType.ToString(), DataType = a.DataType.ToString(),
ElementDataType = a.ElementDataType?.ToString(),
IsLocked = a.IsLocked, IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference DataSourceReference = a.DataSourceReference
}).ToList(), }).ToList(),
@@ -1441,9 +1442,13 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user) private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
{ {
var svc = sp.GetRequiredService<TemplateService>(); 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) 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, Value = cmd.Value,
Description = cmd.Description, Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference, DataSourceReference = cmd.DataSourceReference,
@@ -1456,9 +1461,13 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user) private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
{ {
var svc = sp.GetRequiredService<TemplateService>(); 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) 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, Value = cmd.Value,
Description = cmd.Description, Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference, DataSourceReference = cmd.DataSourceReference,
@@ -1468,6 +1477,70 @@ public class ManagementActor : ReceiveActor
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); 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) private static async Task<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
{ {
var svc = sp.GetRequiredService<TemplateService>(); 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.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; 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.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -59,6 +60,14 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new(); private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
private FlattenedConfiguration? _configuration; 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 // DCL manager actor reference for subscribing to tag values
private readonly IActorRef? _dclManager; private readonly IActorRef? _dclManager;
// Maps each tag path to every attribute canonical name that references it. // 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) foreach (var attr in _configuration.Attributes)
{ {
_attributes[attr.CanonicalName] = attr.Value; // MV-8: index resolved attributes for O(1) lookup on the hot
_attributeQualities[attr.CanonicalName] = // TagValueUpdate ingest path (last-wins on duplicate names).
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; _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> /// </summary>
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command) 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 // Publish attribute change to stream (WP-23) and notify children
var changed = new AttributeValueChanged( var changed = new AttributeValueChanged(
@@ -362,11 +431,37 @@ public class InstanceActor : ReceiveActor
return; 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( var writeRequest = new WriteTagRequest(
correlationId, correlationId,
resolved.BoundDataConnectionName!, resolved.BoundDataConnectionName!,
resolved.DataSourceReference!, resolved.DataSourceReference!,
command.Value, writeValue,
DateTimeOffset.UtcNow); DateTimeOffset.UtcNow);
// Ask the DCL and pipe the result back to the original caller. The DCL // 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)) if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
return; 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. // 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) 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, _instanceUniqueName, update.TagPath, attrName,
value, update.Quality.ToString(), update.Timestamp); value, update.Quality.ToString(), update.Timestamp));
HandleAttributeValueChanged(changed);
} }
} }
/// <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&lt;T&gt;</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&lt;elementClrType&gt;</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) private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
{ {
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}", _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) 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( _logger.LogDebug(
@@ -1,3 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary> /// <summary>
@@ -53,7 +55,7 @@ public class AttributeAccessor
// on the DCL round-trip for data-connected attributes. The async // on the DCL round-trip for data-connected attributes. The async
// variants (GetAsync/SetAsync) are preferred where awaiting is possible. // variants (GetAsync/SetAsync) are preferred where awaiting is possible.
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult(); 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> /// <summary>
@@ -70,7 +72,7 @@ public class AttributeAccessor
/// <param name="value">The value to set.</param> /// <param name="value">The value to set.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public Task SetAsync(string key, object? value) 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> /// <summary>
@@ -175,6 +175,7 @@ public class FlatteningService
CanonicalName = attr.Name, CanonicalName = attr.Name,
Value = attr.Value, Value = attr.Value,
DataType = attr.DataType.ToString(), DataType = attr.DataType.ToString(),
ElementDataType = attr.ElementDataType?.ToString(),
IsLocked = attr.IsLocked, IsLocked = attr.IsLocked,
Description = attr.Description, Description = attr.Description,
DataSourceReference = attr.DataSourceReference, DataSourceReference = attr.DataSourceReference,
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
/// overrides that were previously blocked (TemplateEngine-022). /// overrides that were previously blocked (TemplateEngine-022).
/// ///
/// Override granularity: /// 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. /// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed.
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, ExecutionTimeoutSeconds, params/return overridable; Name fixed. /// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, ExecutionTimeoutSeconds, params/return overridable; Name fixed.
/// - Lock flag applies to the entire member (attribute/alarm/script). /// - 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)."; 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 // DataSourceReference is fixed — cannot change
if (proposed.DataSourceReference != original.DataSourceReference) if (proposed.DataSourceReference != original.DataSourceReference)
{ {
@@ -982,6 +982,7 @@ public class TemplateService
{ {
Value = attr.Value, Value = attr.Value,
DataType = attr.DataType, DataType = attr.DataType,
ElementDataType = attr.ElementDataType,
IsLocked = attr.IsLocked, IsLocked = attr.IsLocked,
Description = attr.Description, Description = attr.Description,
DataSourceReference = attr.DataSourceReference, DataSourceReference = attr.DataSourceReference,
@@ -1,4 +1,6 @@
using System.Text.Json; 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; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
@@ -48,6 +50,11 @@ public class SemanticValidator
attributeMap.TryAdd(a.CanonicalName, a); 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 // Collect alarm on-trigger script names for cross-call violation checks
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal); var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms) foreach (var alarm in configuration.Alarms)
@@ -250,6 +257,77 @@ public class SemanticValidator
return new ValidationResult { Errors = errors, Warnings = warnings }; 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( private static void ValidateCallParameters(
string callerName, string callerName,
CallTarget call, 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. /// 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 /// 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). /// (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> /// </summary>
public class ValidationService public class ValidationService
{ {
@@ -1045,6 +1045,7 @@ public sealed class BundleImporter : IBundleImporter
IsLocked = a.IsLocked, IsLocked = a.IsLocked,
Description = a.Description, Description = a.Description,
DataSourceReference = a.DataSourceReference, DataSourceReference = a.DataSourceReference,
ElementDataType = a.ElementDataType,
}); });
} }
foreach (var al in dto.Alarms) foreach (var al in dto.Alarms)
@@ -1082,8 +1083,8 @@ public sealed class BundleImporter : IBundleImporter
/// enumerates for the "Template overwritten" action. /// enumerates for the "Template overwritten" action.
/// <para> /// <para>
/// Update detection compares every scalar field (Value, DataType, /// Update detection compares every scalar field (Value, DataType,
/// IsLocked, Description, DataSourceReference) — no field change → no /// ElementDataType, IsLocked, Description, DataSourceReference) — no field
/// audit row, so an idempotent overwrite produces no noise. /// change → no audit row, so an idempotent overwrite produces no noise.
/// </para> /// </para>
/// </summary> /// </summary>
private async Task SyncTemplateAttributesAsync( private async Task SyncTemplateAttributesAsync(
@@ -1122,7 +1123,8 @@ public sealed class BundleImporter : IBundleImporter
current.DataType != attrDto.DataType || current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked || current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) || !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; if (!changed) continue;
current.Value = attrDto.Value; current.Value = attrDto.Value;
@@ -1130,6 +1132,7 @@ public sealed class BundleImporter : IBundleImporter
current.IsLocked = attrDto.IsLocked; current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description; current.Description = attrDto.Description;
current.DataSourceReference = attrDto.DataSourceReference; current.DataSourceReference = attrDto.DataSourceReference;
current.ElementDataType = attrDto.ElementDataType;
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false); await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync( await _auditService.LogAsync(
user, user,
@@ -1143,6 +1146,7 @@ public sealed class BundleImporter : IBundleImporter
AttributeName = current.Name, AttributeName = current.Name,
current.Value, current.Value,
current.DataType, current.DataType,
current.ElementDataType,
current.IsLocked, current.IsLocked,
current.Description, current.Description,
current.DataSourceReference, current.DataSourceReference,
@@ -1158,6 +1162,7 @@ public sealed class BundleImporter : IBundleImporter
IsLocked = attrDto.IsLocked, IsLocked = attrDto.IsLocked,
Description = attrDto.Description, Description = attrDto.Description,
DataSourceReference = attrDto.DataSourceReference, DataSourceReference = attrDto.DataSourceReference,
ElementDataType = attrDto.ElementDataType,
}; };
ex.Attributes.Add(newAttr); ex.Attributes.Add(newAttr);
await _auditService.LogAsync( await _auditService.LogAsync(
@@ -1172,6 +1177,7 @@ public sealed class BundleImporter : IBundleImporter
AttributeName = newAttr.Name, AttributeName = newAttr.Name,
newAttr.Value, newAttr.Value,
newAttr.DataType, newAttr.DataType,
newAttr.ElementDataType,
newAttr.IsLocked, newAttr.IsLocked,
newAttr.Description, newAttr.Description,
newAttr.DataSourceReference, newAttr.DataSourceReference,
@@ -2300,6 +2306,7 @@ public sealed class BundleImporter : IBundleImporter
CanonicalName = a.Name, CanonicalName = a.Name,
Value = a.Value, Value = a.Value,
DataType = a.DataType.ToString(), DataType = a.DataType.ToString(),
ElementDataType = a.ElementDataType?.ToString(),
IsLocked = a.IsLocked, IsLocked = a.IsLocked,
Description = a.Description, Description = a.Description,
DataSourceReference = a.DataSourceReference, DataSourceReference = a.DataSourceReference,
@@ -80,7 +80,8 @@ public sealed record TemplateAttributeDto(
DataType DataType, DataType DataType,
bool IsLocked, bool IsLocked,
string? Description, string? Description,
string? DataSourceReference); string? DataSourceReference,
DataType? ElementDataType = null);
public sealed record TemplateAlarmDto( public sealed record TemplateAlarmDto(
string Name, string Name,
@@ -51,7 +51,8 @@ public sealed class EntitySerializer
DataType: a.DataType, DataType: a.DataType,
IsLocked: a.IsLocked, IsLocked: a.IsLocked,
Description: a.Description, Description: a.Description,
DataSourceReference: a.DataSourceReference)).ToList(), DataSourceReference: a.DataSourceReference,
ElementDataType: a.ElementDataType)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto( Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Name: a.Name, Name: a.Name,
Description: a.Description, Description: a.Description,
@@ -203,6 +204,7 @@ public sealed class EntitySerializer
IsLocked = a.IsLocked, IsLocked = a.IsLocked,
Description = a.Description, Description = a.Description,
DataSourceReference = a.DataSourceReference, DataSourceReference = a.DataSourceReference,
ElementDataType = a.ElementDataType,
}); });
} }
foreach (var al in dto.Alarms) 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);
}
}
@@ -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&lt;T&gt;</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.True(channel.Reader.TryRead(out var evt));
Assert.Equal("", evt.AttributeChanged.Value); 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);
}
} }
@@ -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&lt;T&gt;</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&lt;T&gt;</c> (the codec's decode result), which the SDK wraps
/// identically to a CLR array — see the <c>List&lt;int&gt;</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); 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] [Fact]
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized() public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{ {
@@ -1,4 +1,5 @@
using Akka.Actor; using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -444,4 +445,575 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("Good", response.Quality); 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&lt;int&gt;</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&lt;int&gt;</c> to <c>"[10,20,30]"</c>; HandleSetDataAttribute must
/// decode that back to a typed <c>List&lt;int&gt;</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.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -84,4 +85,54 @@ public class ScopeAccessorTests
var temp = children["TempSensor"]; var temp = children["TempSensor"];
Assert.Equal("Motor.TempSensor", temp.Path); 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("ns=2;s=Tank07", result.Value.NativeAlarmSources[0].SourceReference);
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source); 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); 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] [Fact]
public void ValidateAlarmOverride_LockedAlarm_ReturnsError() public void ValidateAlarmOverride_LockedAlarm_ReturnsError()
{ {
@@ -1056,4 +1056,217 @@ public class SemanticValidatorTests
var result = _sut.Validate(config); var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); 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); var sys = Assert.Single(aggregate.ExternalSystems);
Assert.Null(sys.AuthConfiguration); 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);
}
} }