fix(ablegacy): coalesceReads absent means default; the form never pins it

Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning
today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads
is now bool? (null = default ON, never serialised, same encoding as
Transport); the form omits the key when the switch is on and writes only an
explicit false. Persisted coalesceReads:true keeps loading as on.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:24:30 -04:00
parent 16c2045cab
commit efa040813a
6 changed files with 17 additions and 9 deletions
@@ -51,9 +51,12 @@ public sealed class AbLegacyDriverOptions
/// <summary>
/// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
/// (driver-config key <c>coalesceReads</c>, default <see langword="true"/>). A block the device refuses falls
/// back to per-tag reads on its own; set this false to read every tag singly.
/// back to per-tag reads on its own; set this false to read every tag singly. <see langword="null"/> (the key
/// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
/// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
/// </summary>
public bool CoalesceReads { get; init; } = true;
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public bool? CoalesceReads { get; init; }
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
@@ -72,7 +72,7 @@ public sealed partial class AbLegacyDriver
/// </summary>
private List<ReadUnit> PlanReadUnits(DeviceState device, List<ReadItem> items)
{
if (!_options.CoalesceReads || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
var units = new List<ReadUnit>(items.Count);
var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List<ReadItem>>();
@@ -97,7 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
CoalesceReads = dto.CoalesceReads ?? true,
CoalesceReads = dto.CoalesceReads,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -208,7 +208,7 @@
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
CoalesceReads = o.CoalesceReads,
CoalesceReads = o.CoalesceReads ?? true,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -229,7 +229,8 @@
},
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
CoalesceReads = CoalesceReads,
// On = the default: OMIT the key (never pin a concrete default); off is the only value written.
CoalesceReads = CoalesceReads ? null : false,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
@@ -180,5 +180,5 @@ public sealed class AbLegacyReadCoalescingTests
[InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
[InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads.ShouldBe(expected);
(AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected);
}
@@ -269,8 +269,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests
var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
.CoalesceReads.ShouldBe(edited);
(ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
.CoalesceReads != false).ShouldBe(edited);
// Review 07 r0 M9: ON is the default and is OMITTED, never pinned; only an explicit off is written.
var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
if (edited) root.ContainsKey("coalesceReads").ShouldBeFalse();
else root["coalesceReads"]!.GetValue<bool>().ShouldBeFalse();
}
[Fact]