docs: implementation plan for structured multi-value (List) attributes
15 tasks (MV-1..MV-15) with classifications, dependencies, and TDD steps: type model, AttributeValueCodec, idempotent migration, flatten, validation, runtime encode/decode, DCL array coercion, stream encode, management, CLI, transport, two UI editors, and integration verification.
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
# Structured Multi-Value (List) Attribute — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a first-class `DataType.List` attribute type (a homogeneous list of any scalar element type) that round-trips through authoring, flatten, site runtime, OPC UA read/write, streaming, UI, and CLI.
|
||||
|
||||
**Architecture:** One new `DataType.List` enum member + a nullable `ElementDataType` companion on the attribute entities and `ResolvedAttribute`. A single round-trippable `AttributeValueCodec` (JSON array, invariant culture) encodes/decodes list values everywhere they are stored or transmitted; scalars keep their current string behavior unchanged. The script-accessor `.ToString()` boundary and the InstanceActor in-memory store are the core runtime changes.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, Akka.NET 1.5, EF Core 10 (MS SQL + SQLite), gRPC, Blazor Server, System.CommandLine CLI.
|
||||
|
||||
**Design doc:** `docs/plans/2026-06-16-multivalue-attribute-design.md` (approved).
|
||||
**Branch:** `feature/multivalue-attribute` (off main; design committed `b238228`).
|
||||
|
||||
**Conventions for every task:**
|
||||
- TDD: write the failing test, see it fail, implement, see it pass, commit.
|
||||
- Targeted builds/tests only — build the affected project(s) and run the filtered test(s); a full-solution build runs once in the final task.
|
||||
- Build a project: `dotnet build src/<Project>/<Project>.csproj`
|
||||
- Run filtered tests: `dotnet test tests/<TestProject>/<TestProject>.csproj --filter <Name>`
|
||||
- Allowed element types (the 6 scalars): `String, Int32, Float, Double, Boolean, DateTime`. **Not** `Binary`, **not** nested `List`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-1: Type model — enum member + ElementDataType companion
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (foundation; everything else depends on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs` (the `ResolvedAttribute` record, ~line 57)
|
||||
|
||||
**Step 1 — Add the enum member.** Append `List` as the last member of `DataType` (append-only; do not reorder — the enum is persisted by name via `HasConversion<string>`, but appended-last is the safe convention):
|
||||
|
||||
```csharp
|
||||
public enum DataType
|
||||
{
|
||||
Boolean,
|
||||
Int32,
|
||||
Float,
|
||||
Double,
|
||||
String,
|
||||
DateTime,
|
||||
Binary,
|
||||
List
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Add `ElementDataType` to the two entities and the resolved record.**
|
||||
|
||||
`TemplateAttribute.cs` (after the `DataType DataType` property, ~line 26):
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// For <see cref="Enums.DataType.List"/> attributes: the scalar type of each
|
||||
/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar
|
||||
/// attributes. The element type is fixed by the base attribute and cannot be
|
||||
/// changed on a derived template or instance override.
|
||||
/// </summary>
|
||||
public DataType? ElementDataType { get; set; }
|
||||
```
|
||||
|
||||
`InstanceAttributeOverride.cs` (after `OverrideValue`, ~line 12): add the same `public DataType? ElementDataType { get; set; }` property (add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;`).
|
||||
|
||||
`FlattenedConfiguration.cs` — `ResolvedAttribute` record (after `DataType` at ~line 68). NOTE: `ResolvedAttribute.DataType` is a **string**; keep `ElementDataType` a nullable string here for symmetry with that record's existing style:
|
||||
```csharp
|
||||
/// <summary>For List attributes: the element scalar type name; null otherwise.</summary>
|
||||
public string? ElementDataType { get; init; }
|
||||
```
|
||||
|
||||
**Step 3 — Build the project.**
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj`
|
||||
Expected: 0 errors (purely additive).
|
||||
|
||||
**Step 4 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.Commons
|
||||
git commit -m "feat(commons): add DataType.List + ElementDataType companion for multi-value attributes"
|
||||
```
|
||||
|
||||
**Acceptance:** Commons compiles; `DataType.List` exists; both entities and `ResolvedAttribute` carry the nullable element-type field.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-2: AttributeValueCodec (round-trippable JSON encode/decode) + tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-3, MV-4
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs`
|
||||
- Create: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs`
|
||||
|
||||
This codec is the single canonical encoder used for: persisted attribute `Value`, the gRPC wire value, and decode-on-load. `ValueFormatter` stays display-only and is untouched.
|
||||
|
||||
**Step 1 — Write failing tests.** Cover: scalar passthrough (string returned as-is; int/double/bool/DateTime → invariant string), list encode (`List<string>` → `["a","b"]`), embedded comma/quote escaping, empty list → `"[]"`, null → null, DateTime list round-trips ISO-8601, culture-invariance (set `CultureInfo.CurrentCulture` to `de-DE` and assert `Encode(1.5)` is `"1.5"`), decode round-trip for each element type, and decode of malformed JSON throws `FormatException` (caught by callers).
|
||||
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using Xunit;
|
||||
|
||||
public class AttributeValueCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Encode_StringList_ProducesJsonArray() =>
|
||||
Assert.Equal("[\"WO-1\",\"WO-2\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_String_ReturnedAsIs() =>
|
||||
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
|
||||
|
||||
[Fact]
|
||||
public void Encode_Scalar_Double_IsInvariant()
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("1.5", AttributeValueCodec.Encode(1.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_EmptyList_IsBracketPair() =>
|
||||
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
|
||||
|
||||
[Fact]
|
||||
public void Encode_StringWithComma_IsEscaped() =>
|
||||
Assert.Equal("[\"ACME, Inc.\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_Int32List()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(new List<int> { 1, 2, 3 });
|
||||
var back = (IList<int>)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!;
|
||||
Assert.Equal(new[] { 1, 2, 3 }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_Scalar_ReturnsString() =>
|
||||
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
|
||||
|
||||
[Fact]
|
||||
public void Decode_MalformedJson_Throws() =>
|
||||
Assert.Throws<FormatException>(() =>
|
||||
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Run, expect FAIL** (`AttributeValueCodec` not defined).
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests`
|
||||
|
||||
**Step 3 — Implement.** Use `System.Text.Json` with invariant formatting. For decode, reuse the element-conversion idea from `ScriptParameters.ConvertScalar` (`src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:161`) — keep the codec self-contained (small private scalar-parser switch on `DataType`).
|
||||
|
||||
```csharp
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical, round-trippable codec for attribute values. Scalars encode to an
|
||||
/// invariant-culture string (identical to the historical representation); List
|
||||
/// attributes encode to a JSON array. Used wherever a value is stored or
|
||||
/// transmitted (DB Value column, site SQLite, gRPC wire). <see cref="ValueFormatter"/>
|
||||
/// remains a separate, display-only (comma-joined) formatter.
|
||||
/// </summary>
|
||||
public static class AttributeValueCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||
|
||||
/// <summary>Encodes a value to its canonical string form.</summary>
|
||||
public static string? Encode(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: return null;
|
||||
case string s: return s; // already canonical
|
||||
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
|
||||
case IEnumerable e:
|
||||
var items = e.Cast<object?>()
|
||||
.Select(x => x is IFormattable xf
|
||||
? xf.ToString(null, CultureInfo.InvariantCulture)
|
||||
: x?.ToString());
|
||||
return JsonSerializer.Serialize(items, JsonOpts);
|
||||
default: return value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a canonical string. For <see cref="DataType.List"/> returns a typed
|
||||
/// <c>List<T></c>; for scalars returns the string unchanged. Throws
|
||||
/// <see cref="FormatException"/> on malformed list JSON or an un-parseable element.
|
||||
/// </summary>
|
||||
public static object? Decode(string? value, DataType dataType, DataType? elementType)
|
||||
{
|
||||
if (dataType != DataType.List) return value; // scalar: unchanged
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (elementType is null)
|
||||
throw new FormatException("List attribute requires an element type.");
|
||||
|
||||
string?[] raw;
|
||||
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
|
||||
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
|
||||
|
||||
var clrType = ElementClrType(elementType.Value);
|
||||
var listType = typeof(List<>).MakeGenericType(clrType);
|
||||
var result = (IList)Activator.CreateInstance(listType)!;
|
||||
foreach (var item in raw)
|
||||
result.Add(ParseScalar(item, elementType.Value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Type ElementClrType(DataType t) => t switch
|
||||
{
|
||||
DataType.String => typeof(string),
|
||||
DataType.Int32 => typeof(int),
|
||||
DataType.Float => typeof(float),
|
||||
DataType.Double => typeof(double),
|
||||
DataType.Boolean => typeof(bool),
|
||||
DataType.DateTime => typeof(DateTime),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
|
||||
private static object? ParseScalar(string? s, DataType t)
|
||||
{
|
||||
if (s is null) throw new FormatException("List elements may not be null.");
|
||||
var c = CultureInfo.InvariantCulture;
|
||||
try
|
||||
{
|
||||
return t switch
|
||||
{
|
||||
DataType.String => s,
|
||||
DataType.Int32 => int.Parse(s, c),
|
||||
DataType.Float => float.Parse(s, c),
|
||||
DataType.Double => double.Parse(s, c),
|
||||
DataType.Boolean => bool.Parse(s),
|
||||
DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind),
|
||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||
{
|
||||
throw new FormatException($"List element '{s}' is not a valid {t}.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if the type may be a List element scalar.</summary>
|
||||
public static bool IsValidElementType(DataType t) =>
|
||||
t is DataType.String or DataType.Int32 or DataType.Float
|
||||
or DataType.Double or DataType.Boolean or DataType.DateTime;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — Run tests, expect PASS.**
|
||||
|
||||
**Step 5 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs
|
||||
git commit -m "feat(commons): AttributeValueCodec for canonical list value encode/decode"
|
||||
```
|
||||
|
||||
**Acceptance:** all codec tests pass; scalars unchanged; lists round-trip; malformed JSON throws `FormatException`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-3: EF mapping + idempotent migration
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-2, MV-4
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:111-122`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs:102-103`
|
||||
- Create: a new migration under `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/`
|
||||
|
||||
**Step 1 — EF config.** In `TemplateConfiguration.Configure`: widen `Value` and map the new column:
|
||||
```csharp
|
||||
builder.Property(a => a.Value); // remove .HasMaxLength(4000) → defaults to nvarchar(max)
|
||||
|
||||
builder.Property(a => a.ElementDataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
```
|
||||
In `InstanceConfiguration` (the `InstanceAttributeOverride` config): drop `.HasMaxLength(4000)` from `OverrideValue`, and add the same `ElementDataType` mapping.
|
||||
|
||||
**Step 2 — Generate the migration.**
|
||||
```bash
|
||||
dotnet ef migrations add AddListAttributeElementType \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
|
||||
**Step 3 — Make `Up`/`Down` idempotent** (per open follow-up #70 — re-running against a partially-migrated prod DB must not throw). Wrap the generated `AddColumn`/`AlterColumn` calls with existence guards via `migrationBuilder.Sql(...)`:
|
||||
```csharp
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes'))
|
||||
ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides'))
|
||||
ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;");
|
||||
migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;");
|
||||
```
|
||||
Replace the auto-generated `AddColumn`/`AlterColumn` statements with the guarded SQL above (keep the `.Designer.cs` snapshot the tool generated — only the `Up`/`Down` body becomes guarded SQL). Provide a `Down` that drops the columns if present and restores `nvarchar(4000)`.
|
||||
|
||||
**Step 4 — Verify no model drift.**
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
|
||||
Then confirm there are no pending model changes:
|
||||
```bash
|
||||
dotnet ef migrations has-pending-model-changes \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
Expected: "No changes have been made to the model since the last migration" (or exit 0).
|
||||
|
||||
**Step 5 — Commit.**
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase
|
||||
git commit -m "feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent)"
|
||||
```
|
||||
|
||||
**Acceptance:** project builds; no pending model changes; migration `Up`/`Down` are guarded/idempotent.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-4: Flatten carries ElementDataType into ResolvedAttribute
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-2, MV-3
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:177` (and any other site that constructs a `ResolvedAttribute`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs`
|
||||
|
||||
**Step 1 — Failing test:** flatten a template whose attribute is `DataType.List` / `ElementDataType=String` and assert the `ResolvedAttribute` has `DataType=="List"` and `ElementDataType=="String"`; assert an instance override of that attribute keeps `ElementDataType` (override replaces value, not element type).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** wherever `ResolvedAttribute` is built (e.g. `DataType = attr.DataType.ToString()`), also set `ElementDataType = attr.ElementDataType?.ToString()`. Grep for `new ResolvedAttribute` and `DataType = ` in `FlatteningService.cs` to find every construction site and the override-merge path.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(template): carry ElementDataType through flatten/override`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-5: Semantic validation for List attributes
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-6, MV-8, MV-9 (different files)
|
||||
**Blocked by:** MV-1, MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18-21,130-193`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs`
|
||||
|
||||
**Rules to enforce (write a failing test for each first):**
|
||||
1. `DataType.List` requires a non-null `ElementDataType` that is a valid element scalar (`AttributeValueCodec.IsValidElementType`); a scalar attribute must have `ElementDataType == null`. Violation → validation Error.
|
||||
2. An authored default `Value` on a List attribute, if present, must `AttributeValueCodec.Decode` without throwing (catch `FormatException` → Error with the element type and message).
|
||||
3. A List attribute used as an operand in a numeric trigger (HiLo / RangeViolation) or a binary trigger → Error. Extend the existing `NumericDataTypes` operand check (~line 130-193): a `List` operand is never numeric and never a valid binary operand.
|
||||
|
||||
**Steps:** failing tests → run (FAIL) → implement the three checks → run (PASS) → commit `feat(validation): semantic checks for List attributes (element type, default value, trigger operands)`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-6: Script-accessor encode boundary
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-8, MV-9
|
||||
**Blocked by:** MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:56,73`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/...` (accessor or runtime-context test; create if absent)
|
||||
|
||||
**Problem:** `AttributeAccessor` set/`SetAsync` currently do `value?.ToString()`, which turns a `List<string>` into `"System.Collections.Generic.List`1[System.String]"`.
|
||||
|
||||
**Step 1 — Failing test:** a fake/seam `ScriptRuntimeContext.SetAttribute(name, encoded)` capturing the encoded string; assert setting a `List<string>{"a","b"}` sends `["a","b"]` and setting a scalar `"x"` still sends `"x"`.
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** replace both `.ToString()` sites with the codec:
|
||||
```csharp
|
||||
set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty)
|
||||
.GetAwaiter().GetResult();
|
||||
// and in SetAsync:
|
||||
=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty);
|
||||
```
|
||||
Add `using ZB.MOM.WW.ScadaBridge.Commons.Types;`.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString())`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-7: InstanceActor decode (load + static set + override merge)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** MV-1, MV-2, MV-4, MV-6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:114-122` (load), `:307-320` (`HandleSetStaticAttributeCore`), and the static-override merge on load (grep `GetStaticOverridesAsync` usage)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs`
|
||||
|
||||
**Goal:** `_attributes` holds a typed `List<T>` for List attributes so `HandleGetAttribute` returns a real list to scripts, and the canonical JSON string is what gets persisted + streamed.
|
||||
|
||||
**Step 1 — Failing tests** (use the existing InstanceActor test harness / TestKit):
|
||||
- Load a flattened config with a List attribute default `["a","b"]` → `GetAttributeRequest` returns an `IEnumerable` of `{"a","b"}` (not the raw string).
|
||||
- `SetStaticAttributeCommand` with `Value = "[\"x\",\"y\"]"` on a List attribute → subsequent get returns `{"x","y"}`; assert `SetStaticOverrideAsync` was called with the JSON string `["x","y"]` (persisted form is canonical JSON).
|
||||
- A persisted SQLite override `["p","q"]` applied on load decodes to a list.
|
||||
- Malformed stored value → attribute loads with Bad quality, actor does not throw (wrap `Decode` in try/catch → log + set quality Bad).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement.**
|
||||
- Add a small private helper on the actor:
|
||||
```csharp
|
||||
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
|
||||
{
|
||||
var dataType = Enum.Parse<DataType>(attr.DataType, ignoreCase: true);
|
||||
var elementType = string.IsNullOrEmpty(attr.ElementDataType)
|
||||
? (DataType?)null
|
||||
: Enum.Parse<DataType>(attr.ElementDataType, ignoreCase: true);
|
||||
try { return AttributeValueCodec.Decode(raw, dataType, elementType); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Attribute '{Attr}' on '{Instance}' has an undecodable value; marking Bad quality",
|
||||
attr.CanonicalName, _instanceUniqueName);
|
||||
return null; // caller sets quality Bad
|
||||
}
|
||||
}
|
||||
```
|
||||
- Load loop (`:116-121`): `_attributes[attr.CanonicalName] = DecodeAttributeValue(attr, attr.Value);` and set quality `Bad` when a List value failed to decode (non-null raw but null result).
|
||||
- Apply the same decode when merging persisted static overrides (the `GetStaticOverridesAsync` merge).
|
||||
- `HandleSetStaticAttributeCore` (`:309`): look up the `ResolvedAttribute` for `command.AttributeName`; store `DecodeAttributeValue(resolved, command.Value)` in `_attributes` (so reads are typed) while continuing to **persist and publish `command.Value`** (the canonical JSON string) unchanged. The published `AttributeValueChanged` keeps carrying the canonical string — `StreamRelayActor` (MV-9) handles encoding uniformly.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(siteruntime): decode List attributes to typed lists in InstanceActor (load/set/override)`.
|
||||
|
||||
**Acceptance:** scripts read List attributes as `List<T>`; persisted + streamed form is canonical JSON; undecodable values degrade to Bad quality without crashing the actor.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-8: DCL OPC UA array read coercion
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-5, MV-6, MV-9
|
||||
**Blocked by:** MV-1, MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (read/subscription value handling) — and the InstanceActor handler that ingests a DCL value for a data-sourced attribute (grep for where incoming tag values update `_attributes` / publish `AttributeValueChanged`).
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/...`
|
||||
|
||||
**Goal:** when a data-sourced attribute is declared `DataType.List`, an incoming OPC UA array value becomes a typed list with each element coerced to `ElementDataType`; element-type mismatch → Bad quality + log (non-fatal). The **write** path needs no change (`RealOpcUaClient.WriteValueAsync` already wraps in `Variant`, which serializes a `List<T>`/`T[]` as an OPC UA array — add a test asserting an array value writes without exception).
|
||||
|
||||
**Steps:** failing tests (array read → typed list of correct element type; mismatched element → Bad quality; array write via Variant succeeds) → run (FAIL) → implement coercion using `AttributeValueCodec`/`Convert` per element → run (PASS) → commit `feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-9: StreamRelayActor canonical-JSON encode
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~2 min
|
||||
**Parallelizable with:** MV-5, MV-6, MV-8
|
||||
**Blocked by:** MV-2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:48`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/...`
|
||||
|
||||
**Step 1 — Failing test:** relay an `AttributeValueChanged` whose `Value` is a `List<string>{"a","b"}` → assert the produced `AttributeValueUpdate.Value == "[\"a\",\"b\"]"`; relay a scalar string `"x"` → assert `Value == "x"` (unchanged).
|
||||
|
||||
**Step 2 — Run, expect FAIL.**
|
||||
|
||||
**Step 3 — Implement:** replace `Value = ValueFormatter.FormatDisplayValue(msg.Value),` with `Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,` (add the `using`). This is additive — List is a new type, no existing wire consumer relies on comma-joined lists; the proto `string value` field is unchanged.
|
||||
|
||||
**Step 4 — Run, expect PASS.**
|
||||
|
||||
**Step 5 — Commit:** `feat(comm): stream List attribute values as canonical JSON`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-10: ManagementActor add/update attribute handlers
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-11 not (blocks it); MV-12/13 share contract
|
||||
**Blocked by:** MV-1, MV-2, MV-5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:~1441-1461` (add/update attribute), `:526` (the `DataType.ToString()` serialization for read-back)
|
||||
- Modify: the management command/message contract that carries an attribute (add `ElementDataType` — additive field; grep for the `AddAttribute`/`UpdateAttribute` command records in `Commons/Messages/...`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/...`
|
||||
|
||||
**Goal:** the add/update-attribute path accepts an optional element-type, validates `(DataType, ElementDataType, Value)` via the MV-5 rules + `AttributeValueCodec`, and persists both columns. Read-back includes `ElementDataType`.
|
||||
|
||||
**Steps:** failing tests (add a List attribute with element type String + JSON default → persisted with both columns; add List without element type → rejected; add List with bad default → rejected) → run (FAIL) → implement parse/validate/persist → run (PASS) → commit `feat(mgmt): accept + validate ElementDataType on attribute add/update`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-11: CLI element-type + JSON value
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** MV-12, MV-13
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:140-199` (attribute add/update) and the instance-override command if present
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document `--element-type` + JSON `--value`)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/...` (option parsing)
|
||||
|
||||
**Goal:** add `--element-type <Scalar>`; `--value` accepts a JSON array for List attributes. Validate element type client-side before sending; surface server validation errors.
|
||||
|
||||
**Steps:** failing test (parse `--data-type List --element-type String --value '["a","b"]'` → request carries List + String + JSON) → run (FAIL) → implement option + plumb to the management call → run (PASS) → commit `feat(cli): --element-type and JSON --value for List attributes`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-12: Transport DTO + importer field
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** MV-4, MV-9, MV-11, MV-13
|
||||
**Blocked by:** MV-1
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/...` or `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:77-83` (`TemplateAttributeDto`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:~2300-2306`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...` (round-trip export→import of a List attribute)
|
||||
|
||||
**Goal:** the export/import DTO carries `ElementDataType`; a List attribute survives an export→import round-trip. Old bundles without the field import as scalars (null element type) — assert backward-compat.
|
||||
|
||||
**Steps:** failing round-trip test → run (FAIL) → add `ElementDataType` to the DTO + importer mapping → run (PASS) → commit `feat(transport): round-trip ElementDataType for List attributes`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-13: Central UI — TemplateEdit list editor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-11, MV-12, MV-14
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:82,484-491,556-566`
|
||||
- (Optional) Create: a small `AttributeListEditor.razor` shared component under `CentralUI/Components/Shared/`
|
||||
- Test/verify: Playwright fixture if the harness covers TemplateEdit; otherwise manual verification noted in the task.
|
||||
|
||||
Use the **frontend-design** skill for the editor UI (clean corporate Bootstrap, no third-party component frameworks — per CLAUDE.md).
|
||||
|
||||
**Goal:** when the attribute `DataType` dropdown = `List`, reveal an `ElementDataType` dropdown (the 6 scalars) and a repeatable add/remove row editor bound to the JSON value. Inline per-element validation by element type. The dropdown already enumerates `Enum.GetValues<DataType>()`, so `List` appears automatically — gate the element-type + list editor on `_attrDataType == DataType.List`.
|
||||
|
||||
**Steps:** implement the conditional editor; bind to `AttributeValueCodec.Encode` of the rows for submit; decode existing JSON into rows on edit; build `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/...`; verify (Playwright or manual) → commit `feat(ui): List attribute editor in TemplateEdit`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-14: Central UI — InstanceConfigure override list editor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** MV-13
|
||||
**Blocked by:** MV-10
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`
|
||||
- Reuse: the `AttributeListEditor.razor` component from MV-13 if created.
|
||||
|
||||
**Goal:** overriding a List attribute on an instance uses the same list editor (whole-list replacement; element type shown read-only — fixed by base). Clearing the override removes it.
|
||||
|
||||
**Steps:** implement using the shared editor; element-type dropdown is read-only here; build CentralUI; verify → commit `feat(ui): List attribute override editor in InstanceConfigure`.
|
||||
|
||||
---
|
||||
|
||||
### Task MV-15: Integration verification + docs/README sync
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Blocked by:** all (MV-1 … MV-14)
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` and/or `docs/requirements/Component-*.md` if a DataType/attribute capability note needs updating (Template Engine / Commons).
|
||||
- Modify: `docs/plans/2026-06-16-multivalue-attribute-design.md` — mark Status complete.
|
||||
|
||||
**Steps:**
|
||||
1. Full-solution build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect 0 errors.
|
||||
2. Run the feature's targeted test classes across the touched test projects (codec, flatten, validation, InstanceActor, DCL, comm, transport, CLI).
|
||||
3. End-to-end smoke (optional, if exercising the cluster): `bash docker/deploy.sh`, then via CLI create a template with a List attribute (`--data-type List --element-type String --value '["WO-1","WO-2"]'`), deploy an instance, and confirm the value flows to the DebugView as `["WO-1","WO-2"]`.
|
||||
4. Update docs; sync the design-decision note if the high-level requirements track the DataType set.
|
||||
5. Commit: `docs: mark multi-value attribute feature complete; sync README/component notes`.
|
||||
|
||||
**Acceptance:** full build green; all targeted tests pass; (if run) the value round-trips end-to-end through the cluster.
|
||||
|
||||
---
|
||||
|
||||
## Parallelization summary
|
||||
|
||||
- **Wave 1:** MV-1 (foundation, solo).
|
||||
- **Wave 2 (after MV-1):** MV-2, MV-3, MV-4, MV-12 in parallel (disjoint files).
|
||||
- **Wave 3 (after MV-2):** MV-5, MV-6, MV-8, MV-9 in parallel; MV-7 after MV-6.
|
||||
- **Wave 4 (after MV-5):** MV-10; then MV-11, MV-13, MV-14 in parallel after MV-10.
|
||||
- **Wave 5:** MV-15 (final, solo).
|
||||
|
||||
## Risk notes
|
||||
|
||||
- **MV-3** (migration) and **MV-7** (actor model) are the high-risk tasks — full review chain.
|
||||
- The migration must be idempotent (open follow-up #70 is the cautionary precedent).
|
||||
- The gRPC change (MV-9) is additive — `List` is a brand-new type, so no existing wire consumer breaks; the proto field is unchanged.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-16-multivalue-attribute.md",
|
||||
"designDoc": "docs/plans/2026-06-16-multivalue-attribute-design.md",
|
||||
"branch": "feature/multivalue-attribute",
|
||||
"tasks": [
|
||||
{"id": 77, "ref": "MV-1", "subject": "Type model — DataType.List + ElementDataType companion", "class": "standard", "status": "pending"},
|
||||
{"id": 78, "ref": "MV-2", "subject": "AttributeValueCodec + tests", "class": "standard", "status": "pending", "blockedBy": [77]},
|
||||
{"id": 79, "ref": "MV-3", "subject": "EF mapping + idempotent migration", "class": "high-risk", "status": "pending", "blockedBy": [77]},
|
||||
{"id": 80, "ref": "MV-4", "subject": "Flatten carries ElementDataType", "class": "small", "status": "pending", "blockedBy": [77]},
|
||||
{"id": 81, "ref": "MV-5", "subject": "Semantic validation for List attributes", "class": "standard", "status": "pending", "blockedBy": [77, 78]},
|
||||
{"id": 82, "ref": "MV-6", "subject": "Script-accessor encode boundary", "class": "small", "status": "pending", "blockedBy": [78]},
|
||||
{"id": 83, "ref": "MV-7", "subject": "InstanceActor decode (load + set + override)", "class": "high-risk", "status": "pending", "blockedBy": [77, 78, 80, 82]},
|
||||
{"id": 84, "ref": "MV-8", "subject": "DCL OPC UA array read coercion", "class": "standard", "status": "pending", "blockedBy": [77, 78]},
|
||||
{"id": 85, "ref": "MV-9", "subject": "StreamRelayActor canonical-JSON encode", "class": "small", "status": "pending", "blockedBy": [78]},
|
||||
{"id": 86, "ref": "MV-10", "subject": "ManagementActor add/update attribute handlers", "class": "standard", "status": "pending", "blockedBy": [77, 78, 81]},
|
||||
{"id": 87, "ref": "MV-11", "subject": "CLI --element-type + JSON --value", "class": "standard", "status": "pending", "blockedBy": [86]},
|
||||
{"id": 88, "ref": "MV-12", "subject": "Transport DTO + importer field", "class": "small", "status": "pending", "blockedBy": [77]},
|
||||
{"id": 89, "ref": "MV-13", "subject": "Central UI — TemplateEdit list editor", "class": "standard", "status": "pending", "blockedBy": [86]},
|
||||
{"id": 90, "ref": "MV-14", "subject": "Central UI — InstanceConfigure override list editor", "class": "standard", "status": "pending", "blockedBy": [86]},
|
||||
{"id": 91, "ref": "MV-15", "subject": "Integration verification + docs/README sync", "class": "standard", "status": "pending", "blockedBy": [77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90]}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
}
|
||||
Reference in New Issue
Block a user