fix(commons): resolve Commons-008 — replace ValueTuple in SetConnectionBindingsCommand with named ConnectionBinding record (CLI, ManagementService, TemplateEngine, CentralUI)
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 1 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ carve-out makes these types compliant by design.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Akka.NET conventions |
|
| Category | Akka.NET conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs:10` |
|
| Location | `src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs:10` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -379,18 +379,26 @@ Replace the tuple with a small named record, e.g.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Open — deferred to a coordinated multi-module change._ The finding is confirmed valid:
|
Resolved 2026-05-16 (commit `<pending>`) — confirmed against the source: the `ValueTuple`
|
||||||
the `ValueTuple` is the only positional/tuple element in `Messages/` and is unfriendly to
|
was the only positional/tuple element in `Messages/` and `System.Text.Json` serializes it
|
||||||
REQ-COM-5a additive evolution. However, `SetConnectionBindingsCommand.Bindings` is not a
|
as `Item1`/`Item2`, unfriendly to REQ-COM-5a additive evolution. Introduced a named record
|
||||||
Commons-internal type — its `IReadOnlyList<(string,int)>` shape is part of a cross-module
|
`ConnectionBinding(string AttributeName, int DataConnectionId)` in
|
||||||
contract consumed by `ScadaLink.CLI` (`InstanceCommands.TryParseBindings` builds a
|
`Messages/Management/InstanceCommands.cs` (alongside the command, matching the existing
|
||||||
`List<(string,int)>` and passes it to the constructor), `ScadaLink.ManagementService`
|
record-per-file convention) and changed `SetConnectionBindingsCommand.Bindings` to
|
||||||
(`ManagementActor` forwards `cmd.Bindings`), and `ScadaLink.TemplateEngine`
|
`IReadOnlyList<ConnectionBinding>`. All consumers were updated in lock-step: `ScadaLink.CLI`
|
||||||
(`InstanceService.SetConnectionBindingsAsync` takes an `IReadOnlyList<(string AttributeName,
|
(`InstanceCommands.TryParseBindings` now builds a `List<ConnectionBinding>`),
|
||||||
int DataConnectionId)>` parameter). Introducing a `ConnectionBinding` record therefore
|
`ScadaLink.TemplateEngine` (`InstanceService.SetConnectionBindingsAsync` parameter),
|
||||||
requires editing those three modules in lock-step, which is outside the scope of this
|
`ScadaLink.ManagementService` (`ManagementActor` forwards `cmd.Bindings` unchanged — the
|
||||||
Commons-only review pass (the constraint forbids touching other modules' source). Left
|
new element type flows through), and one consumer the finding did not list,
|
||||||
Open and flagged for a follow-up cross-module change; the fix itself is unambiguous.
|
`ScadaLink.CentralUI` (`InstanceConfigure.razor`'s `SaveBindings` built a `List<(string,int)>`).
|
||||||
|
A repo-wide `src/` scan confirms no other references remain. Regression tests added in
|
||||||
|
`ConnectionBindingSerializationTests` (`ConnectionBinding_SerializesWithNamedProperties`
|
||||||
|
asserts named `AttributeName`/`DataConnectionId` JSON properties and the absence of
|
||||||
|
`Item1`/`Item2`; `SetConnectionBindingsCommand_RoundTripsThroughJson` asserts a full
|
||||||
|
JSON round-trip); existing parse/forward tests (`ParseBindings_ValidJson_ReturnsPairs`,
|
||||||
|
`SetConnectionBindings_BulkAssignment_Success`) were updated to the new type. Affected
|
||||||
|
suites are green (Commons 226, CLI 77, ManagementService 55, TemplateEngine 287) and
|
||||||
|
`dotnet build ScadaLink.slnx` is clean.
|
||||||
|
|
||||||
### Commons-009 — `Component-Commons.md` is stale relative to the actual file set
|
### Commons-009 — `Component-Commons.md` is stale relative to the actual file set
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public static class InstanceCommands
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool TryParseBindings(
|
internal static bool TryParseBindings(
|
||||||
string json,
|
string json,
|
||||||
out List<(string, int)>? bindings,
|
out List<ConnectionBinding>? bindings,
|
||||||
out string? error)
|
out string? error)
|
||||||
{
|
{
|
||||||
bindings = null;
|
bindings = null;
|
||||||
@@ -88,7 +88,7 @@ public static class InstanceCommands
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new List<(string, int)>(pairs.Count);
|
var result = new List<ConnectionBinding>(pairs.Count);
|
||||||
foreach (var pair in pairs)
|
foreach (var pair in pairs)
|
||||||
{
|
{
|
||||||
if (pair.Count != 2)
|
if (pair.Count != 2)
|
||||||
@@ -107,7 +107,7 @@ public static class InstanceCommands
|
|||||||
error = "The second element of each binding (dataConnectionId) must be an integer.";
|
error = "The second element of each binding (dataConnectionId) must be an integer.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
result.Add((pair[0].GetString()!, connectionId));
|
result.Add(new ConnectionBinding(pair[0].GetString()!, connectionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
bindings = result;
|
bindings = result;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Entities.Templates
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScadaLink.Commons.Messages.Management
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@using ScadaLink.TemplateEngine.Flattening
|
@using ScadaLink.TemplateEngine.Flattening
|
||||||
@using ScadaLink.TemplateEngine.Services
|
@using ScadaLink.TemplateEngine.Services
|
||||||
@@ -467,7 +468,7 @@
|
|||||||
_saving = true;
|
_saving = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bindings = _bindingSelections.Select(kv => (kv.Key, kv.Value)).ToList();
|
var bindings = _bindingSelections.Select(kv => new ConnectionBinding(kv.Key, kv.Value)).ToList();
|
||||||
var user = await GetCurrentUserAsync();
|
var user = await GetCurrentUserAsync();
|
||||||
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
|
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ public record MgmtDeployInstanceCommand(int InstanceId);
|
|||||||
public record MgmtEnableInstanceCommand(int InstanceId);
|
public record MgmtEnableInstanceCommand(int InstanceId);
|
||||||
public record MgmtDisableInstanceCommand(int InstanceId);
|
public record MgmtDisableInstanceCommand(int InstanceId);
|
||||||
public record MgmtDeleteInstanceCommand(int InstanceId);
|
public record MgmtDeleteInstanceCommand(int InstanceId);
|
||||||
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings);
|
/// <summary>
|
||||||
|
/// A single attribute-to-data-connection binding carried by
|
||||||
|
/// <see cref="SetConnectionBindingsCommand"/>. This is a named record (not a
|
||||||
|
/// <c>ValueTuple</c>) so it serializes with stable, named JSON properties and can
|
||||||
|
/// evolve additively per REQ-COM-5a.
|
||||||
|
/// </summary>
|
||||||
|
public record ConnectionBinding(string AttributeName, int DataConnectionId);
|
||||||
|
|
||||||
|
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<ConnectionBinding> Bindings);
|
||||||
public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary<string, string?> Overrides);
|
public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary<string, string?> Overrides);
|
||||||
public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ScadaLink.Commons.Entities.Instances;
|
using ScadaLink.Commons.Entities.Instances;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.TemplateEngine;
|
using ScadaLink.TemplateEngine;
|
||||||
@@ -276,7 +277,7 @@ public class InstanceService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
||||||
int instanceId,
|
int instanceId,
|
||||||
IReadOnlyList<(string AttributeName, int DataConnectionId)> bindings,
|
IReadOnlyList<ConnectionBinding> bindings,
|
||||||
string user,
|
string user,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ScadaLink.CLI.Commands;
|
using ScadaLink.CLI.Commands;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
namespace ScadaLink.CLI.Tests;
|
namespace ScadaLink.CLI.Tests;
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ public class InstanceArgumentParsingTests
|
|||||||
Assert.True(ok);
|
Assert.True(ok);
|
||||||
Assert.Null(error);
|
Assert.Null(error);
|
||||||
Assert.Equal(2, bindings!.Count);
|
Assert.Equal(2, bindings!.Count);
|
||||||
Assert.Equal(("Speed", 5), bindings[0]);
|
Assert.Equal(new ConnectionBinding("Speed", 5), bindings[0]);
|
||||||
Assert.Equal(("Mode", 7), bindings[1]);
|
Assert.Equal(new ConnectionBinding("Mode", 7), bindings[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for Commons-008 — <see cref="SetConnectionBindingsCommand"/>
|
||||||
|
/// previously declared its bindings as <c>IReadOnlyList<(string, int)></c>.
|
||||||
|
/// A <c>ValueTuple</c> serializes as <c>Item1</c>/<c>Item2</c> and cannot evolve
|
||||||
|
/// additively (REQ-COM-5a). It is now a named <see cref="ConnectionBinding"/> record.
|
||||||
|
/// </summary>
|
||||||
|
public class ConnectionBindingSerializationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionBinding_SerializesWithNamedProperties()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new ConnectionBinding("Temperature", 42));
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
Assert.Equal(JsonValueKind.String, doc.RootElement.GetProperty("AttributeName").ValueKind);
|
||||||
|
Assert.Equal("Temperature", doc.RootElement.GetProperty("AttributeName").GetString());
|
||||||
|
Assert.Equal(42, doc.RootElement.GetProperty("DataConnectionId").GetInt32());
|
||||||
|
|
||||||
|
// The ValueTuple failure mode: Item1/Item2 must NOT appear.
|
||||||
|
Assert.False(doc.RootElement.TryGetProperty("Item1", out _));
|
||||||
|
Assert.False(doc.RootElement.TryGetProperty("Item2", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetConnectionBindingsCommand_RoundTripsThroughJson()
|
||||||
|
{
|
||||||
|
var original = new SetConnectionBindingsCommand(
|
||||||
|
7,
|
||||||
|
new List<ConnectionBinding>
|
||||||
|
{
|
||||||
|
new("Speed", 5),
|
||||||
|
new("Mode", 11),
|
||||||
|
});
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(original);
|
||||||
|
var deserialized = JsonSerializer.Deserialize<SetConnectionBindingsCommand>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(deserialized);
|
||||||
|
Assert.Equal(7, deserialized!.InstanceId);
|
||||||
|
Assert.Equal(2, deserialized.Bindings.Count);
|
||||||
|
Assert.Equal("Speed", deserialized.Bindings[0].AttributeName);
|
||||||
|
Assert.Equal(5, deserialized.Bindings[0].DataConnectionId);
|
||||||
|
Assert.Equal("Mode", deserialized.Bindings[1].AttributeName);
|
||||||
|
Assert.Equal(11, deserialized.Bindings[1].DataConnectionId);
|
||||||
|
|
||||||
|
// ConnectionBinding is a record: each element compares by value.
|
||||||
|
Assert.Equal(original.Bindings, deserialized.Bindings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using ScadaLink.Commons.Entities.Instances;
|
|||||||
using ScadaLink.Commons.Entities.Templates;
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.TemplateEngine.Services;
|
using ScadaLink.TemplateEngine.Services;
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ public class InstanceServiceTests
|
|||||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(1);
|
.ReturnsAsync(1);
|
||||||
|
|
||||||
var bindings = new List<(string, int)> { ("Temp", 100), ("Pressure", 200) };
|
var bindings = new List<ConnectionBinding> { new("Temp", 100), new("Pressure", 200) };
|
||||||
var result = await _sut.SetConnectionBindingsAsync(1, bindings, "admin");
|
var result = await _sut.SetConnectionBindingsAsync(1, bindings, "admin");
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
|
|||||||
Reference in New Issue
Block a user