Merge feature/native-typed-json: native-typed JSON for List attribute values + data normalization
List values now encode as native-typed JSON ([10,20], [true,false], ISO dates; strings stay quoted) via AttributeValueCodec; Decode reads both native and the earlier array-of-strings form for every element type. Already-persisted old-form data is normalized on the fly: idempotent central startup normalizer (ListValueNormalizer), active site-SQLite normalization on InstanceActor override-load, and normalize-on-import in the bundle importer. Instance-override writes now stamp ElementDataType (#93/M3). Full solution 0/0; feature-targeted tests green. Plan: docs/plans/2026-06-16-native-typed-json.md.
This commit is contained in:
@@ -64,7 +64,7 @@ public class AttributeValueCodecTests
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("[\"1.5\",\"2.5\"]",
|
||||
Assert.Equal("[1.5,2.5]",
|
||||
AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 }));
|
||||
}
|
||||
finally
|
||||
@@ -73,6 +73,71 @@ public class AttributeValueCodecTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_Int32List_ProducesNativeNumbers() =>
|
||||
Assert.Equal("[10,20,30]",
|
||||
AttributeValueCodec.Encode(new List<int> { 10, 20, 30 }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_BoolList_ProducesNativeBooleans() =>
|
||||
Assert.Equal("[true,false]",
|
||||
AttributeValueCodec.Encode(new List<bool> { true, false }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_StringList_StaysQuoted() =>
|
||||
Assert.Equal("[\"a\",\"b\"]",
|
||||
AttributeValueCodec.Encode(new List<string> { "a", "b" }));
|
||||
|
||||
[Fact]
|
||||
public void Encode_DateTimeList_IsIso8601()
|
||||
{
|
||||
var json = AttributeValueCodec.Encode(
|
||||
new List<DateTime> { new(2026, 6, 16, 0, 0, 0, DateTimeKind.Utc) });
|
||||
Assert.StartsWith("[\"", json); // DateTime list must be an array of quoted strings
|
||||
Assert.Contains("2026-06-16T00:00:00", json);
|
||||
Assert.DoesNotContain("06/16/2026", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_OldStringFloatForm_BackwardCompatible()
|
||||
{
|
||||
var back = (IList<float>)AttributeValueCodec.Decode("[\"1.5\",\"2.25\"]", DataType.List, DataType.Float)!;
|
||||
Assert.Equal(new[] { 1.5f, 2.25f }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_OldStringDateTimeForm_BackwardCompatible()
|
||||
{
|
||||
// The pre-native codec encoded DateTime via IFormattable.ToString(null, InvariantCulture):
|
||||
// "06/16/2026 12:30:45" (US-invariant, no 'T'/'Z'). New Decode must still parse it.
|
||||
var back = (IList<DateTime>)AttributeValueCodec.Decode(
|
||||
"[\"06/16/2026 12:30:45\"]", DataType.List, DataType.DateTime)!;
|
||||
Assert.Equal(new DateTime(2026, 6, 16, 12, 30, 45, DateTimeKind.Unspecified), back[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_NewNativeIntForm_Parses()
|
||||
{
|
||||
var back = (IList<int>)AttributeValueCodec.Decode("[10,20]", DataType.List, DataType.Int32)!;
|
||||
Assert.Equal(new[] { 10, 20 }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_OldStringIntForm_BackwardCompatible()
|
||||
{
|
||||
var back = (IList<int>)AttributeValueCodec.Decode("[\"10\",\"20\"]", DataType.List, DataType.Int32)!;
|
||||
Assert.Equal(new[] { 10, 20 }, back);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[true,false]")]
|
||||
[InlineData("[\"True\",\"False\"]")]
|
||||
public void Decode_BoolForms_BothParse(string json)
|
||||
{
|
||||
var back = (IList<bool>)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!;
|
||||
Assert.Equal(new[] { true, false }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_Int32List()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ListValueNormalizer"/> — the idempotent startup normalizer that
|
||||
/// rewrites already-persisted List attribute values from the old array-of-strings JSON
|
||||
/// form (<c>["10","20"]</c>) to the new native-typed form (<c>[10,20]</c>).
|
||||
/// </summary>
|
||||
public class ListValueNormalizerTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public ListValueNormalizerTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// Seeds a Template parent (satisfies the TemplateAttribute -> Template FK) and returns its Id.
|
||||
private async Task<int> SeedTemplateAsync()
|
||||
{
|
||||
var template = new Template("T1");
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
// Seeds Site + Template + Instance (satisfies the InstanceAttributeOverride -> Instance FK)
|
||||
// and returns the Instance Id.
|
||||
private async Task<int> SeedInstanceAsync()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateAttribute_OldStringForm_RewrittenToNative()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[\"10\",\"20\"]",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[10,20]", reloaded.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateAttribute_AlreadyNative_IsNotRewritten()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[10,20]",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
// No tracked entity should be marked Modified — idempotent no-op.
|
||||
var tracked = _context.ChangeTracker.Entries<TemplateAttribute>()
|
||||
.Where(e => e.State == EntityState.Modified)
|
||||
.ToList();
|
||||
Assert.Empty(tracked);
|
||||
|
||||
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[10,20]", reloaded.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateAttribute_StringList_IsUnchanged()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("stringList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String,
|
||||
Value = "[\"a\",\"b\"]",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[\"a\",\"b\"]", reloaded.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateAttribute_Malformed_IsSkipped_AndSiblingStillNormalized()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("badList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[\"a\"", // malformed JSON
|
||||
});
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("goodList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[\"5\"]", // old form, valid
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Must NOT throw despite the malformed row.
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var bad = await _context.TemplateAttributes.AsNoTracking()
|
||||
.SingleAsync(a => a.Name == "badList");
|
||||
var good = await _context.TemplateAttributes.AsNoTracking()
|
||||
.SingleAsync(a => a.Name == "goodList");
|
||||
|
||||
Assert.Equal("[\"a\"", bad.Value); // skipped, untouched
|
||||
Assert.Equal("[5]", good.Value); // normalized
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateAttribute_NonListRow_IsUnchanged()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("scalar")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.Int32,
|
||||
ElementDataType = null,
|
||||
Value = "42",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var reloaded = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("42", reloaded.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstanceAttributeOverride_OldStringForm_RewrittenToNative()
|
||||
{
|
||||
var instanceId = await SeedInstanceAsync();
|
||||
_context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("intList")
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ElementDataType = DataType.Int32,
|
||||
OverrideValue = "[\"5\"]",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[5]", reloaded.OverrideValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstanceAttributeOverride_NullElementType_IsUntouched()
|
||||
{
|
||||
var instanceId = await SeedInstanceAsync();
|
||||
_context.InstanceAttributeOverrides.Add(new InstanceAttributeOverride("scalar")
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ElementDataType = null,
|
||||
OverrideValue = "42",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
|
||||
var reloaded = await _context.InstanceAttributeOverrides.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("42", reloaded.OverrideValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_IsIdempotent_SecondRunChangesNothing()
|
||||
{
|
||||
var templateId = await SeedTemplateAsync();
|
||||
_context.TemplateAttributes.Add(new TemplateAttribute("intList")
|
||||
{
|
||||
TemplateId = templateId,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.Int32,
|
||||
Value = "[\"10\",\"20\"]",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
var afterFirst = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[10,20]", afterFirst.Value);
|
||||
|
||||
await ListValueNormalizer.NormalizeAsync(_context);
|
||||
var afterSecond = await _context.TemplateAttributes.AsNoTracking().SingleAsync();
|
||||
Assert.Equal("[10,20]", afterSecond.Value);
|
||||
|
||||
var tracked = _context.ChangeTracker.Entries<TemplateAttribute>()
|
||||
.Where(e => e.State == EntityState.Modified)
|
||||
.ToList();
|
||||
Assert.Empty(tracked);
|
||||
}
|
||||
}
|
||||
@@ -1016,4 +1016,159 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[]", overrides["Labels"]);
|
||||
}
|
||||
|
||||
// ── NJ-4: old-form List static override normalization on load ────────────
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
|
||||
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
|
||||
/// when the actor loads it at startup. The in-memory read still returns the
|
||||
/// typed list {10,20}; the on-disk value is normalized to native JSON.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-OldForm",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-OldForm", config);
|
||||
|
||||
// Wait for the async override load (PipeTo) + fire-and-forget normalization.
|
||||
await Task.Delay(1000);
|
||||
|
||||
// In-memory read returns the typed list, decoded from the old form.
|
||||
actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20 }, list);
|
||||
|
||||
// The on-disk override has been normalized to the native form.
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[10,20]", overrides["Counts"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
|
||||
/// load-time normalization must be a no-op — the on-disk value is unchanged
|
||||
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-Native",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-Native", config);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
var list = Assert.IsType<List<int>>(response.Value);
|
||||
Assert.Equal(new[] { 10, 20 }, list);
|
||||
|
||||
// The native value is left untouched on disk.
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-Native");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[10,20]", overrides["Counts"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a scalar static override is unaffected by the List normalization path —
|
||||
/// its on-disk value is left exactly as stored (no native re-encode).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-ScalarOf",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-ScalarOf", config);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Found);
|
||||
Assert.Equal("200.0", response.Value);
|
||||
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("200.0", overrides["Temperature"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
|
||||
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
|
||||
/// the actor stays alive, and the poison on-disk value is left unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
|
||||
{
|
||||
await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\"");
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadOf",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Counts", Value = "[1,2]",
|
||||
DataType = "List", ElementDataType = "Int32"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump-BadOf", config);
|
||||
Watch(actor);
|
||||
await Task.Delay(1000);
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", 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 from the normalization path.
|
||||
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// The malformed value must NOT have been re-persisted (left exactly as stored).
|
||||
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf");
|
||||
Assert.Single(overrides);
|
||||
Assert.Equal("[\"a\"", overrides["Counts"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,106 @@ public class InstanceServiceTests
|
||||
_repoMock.Verify(r => r.AddInstanceConnectionBindingAsync(It.IsAny<InstanceConnectionBinding>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
// --- NJ-2: ElementDataType stamping on SetAttributeOverrideAsync ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_CreatePath_ListAttribute_StampsElementDataType()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>
|
||||
{
|
||||
new("Tags")
|
||||
{
|
||||
IsLocked = false,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String
|
||||
}
|
||||
});
|
||||
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InstanceAttributeOverride>()); // no existing override → create path
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
InstanceAttributeOverride? captured = null;
|
||||
_repoMock.Setup(r => r.AddInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "Tags", "[\"a\"]", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(DataType.String, captured.ElementDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_UpdatePath_ListAttribute_StampsElementDataType()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>
|
||||
{
|
||||
new("Tags")
|
||||
{
|
||||
IsLocked = false,
|
||||
DataType = DataType.List,
|
||||
ElementDataType = DataType.String
|
||||
}
|
||||
});
|
||||
|
||||
var existingOverride = new InstanceAttributeOverride("Tags") { Id = 42, InstanceId = 1, OverrideValue = "[\"old\"]" };
|
||||
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InstanceAttributeOverride> { existingOverride }); // pre-existing → update path
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
InstanceAttributeOverride? captured = null;
|
||||
_repoMock.Setup(r => r.UpdateInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "Tags", "[\"new\"]", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(DataType.String, captured.ElementDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAttributeOverride_CreatePath_ScalarAttribute_ElementDataTypeIsNull()
|
||||
{
|
||||
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(instance);
|
||||
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateAttribute>
|
||||
{
|
||||
new("Threshold")
|
||||
{
|
||||
IsLocked = false,
|
||||
DataType = DataType.Float,
|
||||
ElementDataType = null
|
||||
}
|
||||
});
|
||||
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InstanceAttributeOverride>());
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
InstanceAttributeOverride? captured = null;
|
||||
_repoMock.Setup(r => r.AddInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => captured = o);
|
||||
|
||||
var result = await _sut.SetAttributeOverrideAsync(1, "Threshold", "3.14", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured.ElementDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignToArea_AreaInDifferentSite_ReturnsFailure()
|
||||
{
|
||||
|
||||
@@ -259,6 +259,122 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
|
||||
}
|
||||
|
||||
private static BundleContentDto MakeContentWithListAttribute(
|
||||
string value, DataType elementType)
|
||||
{
|
||||
var template = new TemplateDto(
|
||||
Name: "Pump",
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: new[]
|
||||
{
|
||||
new TemplateAttributeDto(
|
||||
Name: "Tags",
|
||||
Value: value,
|
||||
DataType: DataType.List,
|
||||
IsLocked: false,
|
||||
Description: null,
|
||||
DataSourceReference: null,
|
||||
ElementDataType: elementType),
|
||||
},
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
|
||||
return new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: new[] { template },
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_normalizes_old_form_Int32_list_value_to_native_json()
|
||||
{
|
||||
// Pre-native bundle: quoted Int32 list elements.
|
||||
var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.List, attr.DataType);
|
||||
Assert.Equal(DataType.Int32, attr.ElementDataType);
|
||||
// Imported native: numbers unquoted.
|
||||
Assert.Equal("[10,20]", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_string_list_value_quoted()
|
||||
{
|
||||
var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.List, attr.DataType);
|
||||
Assert.Equal(DataType.String, attr.ElementDataType);
|
||||
// Strings stay quoted in native form.
|
||||
Assert.Equal("[\"a\",\"b\"]", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_malformed_list_value_unchanged_without_throwing()
|
||||
{
|
||||
// Truncated JSON array — Decode throws FormatException; the import must
|
||||
// still succeed and carry the value through verbatim (DB normalizer is
|
||||
// the backstop).
|
||||
var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal("[\"a\"", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_scalar_attribute_value_unchanged()
|
||||
{
|
||||
var template = new TemplateDto(
|
||||
Name: "Sensor",
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: new[]
|
||||
{
|
||||
new TemplateAttributeDto(
|
||||
Name: "Pressure",
|
||||
Value: "42.0",
|
||||
DataType: DataType.Double,
|
||||
IsLocked: false,
|
||||
Description: null,
|
||||
DataSourceReference: null,
|
||||
ElementDataType: null),
|
||||
},
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
var dto = new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: new[] { template },
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.Double, attr.DataType);
|
||||
Assert.Equal("42.0", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user