dd4f1c2aa3
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9 Severity (higher = more severe) while MxGateway native alarms and computed alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer ranks higher as worse, so Worst Severity, the severity filter/sort and the site event-log Error threshold picked the LEAST urgent MxGateway/computed alarms. - AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887, b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500, 751-999 -> 250; <= 0 clamps to Critical, > 999 to Low. - MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority); AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged. - AlarmConditionState gains trailing Priority (int?): the raw source number, exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire AlarmStateUpdate.priority keep that meaning (no proto change); central derives Severity by kind (SeverityFromPriority), so an older site decodes correctly. - ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved from a.Severity to a.Priority, so MES output is unchanged. - AlarmActor raise logs Error when the banded severity >= 700 (same threshold as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700). - NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate. - Debug snapshot decode restores Condition.Priority from the row's priority. - Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI, HighLevelReqs, MES alarm-status plan, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
443 lines
20 KiB
C#
443 lines
20 KiB
C#
using System.Security.Claims;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
|
|
|
|
/// <summary>
|
|
/// bUnit rendering tests for the Operator Alarm Summary page — arch-review UA6
|
|
/// (sortable-header accessibility). The sortable column headers must be keyboard
|
|
/// operable: they carry <c>tabindex="0"</c>, expose their sort direction via
|
|
/// <c>aria-sort</c> (<c>ascending</c>/<c>descending</c> on the active column,
|
|
/// <c>none</c> otherwise), and toggle the sort when Enter is pressed on them —
|
|
/// mirroring the existing pointer-click sort.
|
|
/// </summary>
|
|
public class AlarmSummaryRenderTests : BunitContext
|
|
{
|
|
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
private readonly FakeSiteAlarmLiveCache _liveCache = new();
|
|
|
|
/// <summary>Initializes the test class and configures the faked services and the bUnit context.</summary>
|
|
public AlarmSummaryRenderTests()
|
|
{
|
|
// Two computed rows whose default sort (severity DESC) orders Zeta before Alpha:
|
|
// priority 100 bands to Severity 1000, priority 500 to 750 (Gitea #129 — a lower
|
|
// priority is more urgent). An ascending sort by instance name flips them.
|
|
var rows = new List<AlarmSummaryRow>
|
|
{
|
|
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
|
new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
|
|
};
|
|
|
|
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
|
|
// BuildFromLiveAlarms: flatten the pushed live snapshot the same way
|
|
// the real service does, so the live-path render tests exercise real behavior.
|
|
_summary.BuildFromLiveAlarms(Arg.Any<IReadOnlyList<AlarmStateChanged>>())
|
|
.Returns(ci =>
|
|
{
|
|
var alarms = ci.Arg<IReadOnlyList<AlarmStateChanged>>();
|
|
var built = alarms
|
|
.Select(a => new AlarmSummaryRow(a.InstanceUniqueName, a))
|
|
.OrderBy(r => r.InstanceUniqueName, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
return new AlarmSummaryResult(built, Array.Empty<string>());
|
|
});
|
|
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
|
|
.Returns(new AlarmRollup(2, 900, 0, new Dictionary<AlarmKind, int>()));
|
|
Services.AddSingleton(_summary);
|
|
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
|
{
|
|
new("Site 1", "site1") { Id = 1 },
|
|
new("Site 2", "site2") { Id = 2 },
|
|
}));
|
|
Services.AddSingleton(_siteRepo);
|
|
|
|
Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
|
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
|
|
};
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
|
Services.AddAuthorizationCore();
|
|
Services.AddTestConfigurationMutations();
|
|
}
|
|
|
|
// Renders the page and drives the site picker so the alarm table appears.
|
|
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
|
|
{
|
|
var cut = Render<AlarmSummaryPage>();
|
|
cut.Find("[data-test='alarm-summary-site']").Change("1");
|
|
cut.WaitForAssertion(() =>
|
|
Assert.NotEmpty(cut.FindAll("tr[data-test='alarm-summary-row']")));
|
|
return cut;
|
|
}
|
|
|
|
/// <summary>Verifies that sortable headers carry a tabindex and an aria-sort attribute.</summary>
|
|
[Fact]
|
|
public void SortableHeaders_CarryTabindexAndAriaSort()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
var headers = cut.FindAll("thead th");
|
|
// 0=Instance (sortable), 1=Alarm (sortable), 2=State/Kind (not sortable), 3=Severity (sortable, active).
|
|
var instance = headers[0];
|
|
var stateKind = headers[2];
|
|
var severity = headers[3];
|
|
|
|
Assert.Equal("0", instance.GetAttribute("tabindex"));
|
|
Assert.Equal("0", severity.GetAttribute("tabindex"));
|
|
|
|
// Default sort is severity descending.
|
|
Assert.Equal("descending", severity.GetAttribute("aria-sort"));
|
|
Assert.Equal("none", instance.GetAttribute("aria-sort"));
|
|
|
|
// The non-sortable column is not keyboard-focusable and carries no aria-sort.
|
|
Assert.Null(stateKind.GetAttribute("tabindex"));
|
|
Assert.Null(stateKind.GetAttribute("aria-sort"));
|
|
}
|
|
|
|
/// <summary>Verifies that pressing Enter on the instance header flips the row order.</summary>
|
|
[Fact]
|
|
public void EnterOnInstanceHeader_FlipsRowOrder()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
// Default severity-descending order → Zeta (900) first.
|
|
Assert.Equal("Zeta", FirstRowInstance(cut));
|
|
|
|
// Enter on the Instance header sorts ascending by instance → Alpha first.
|
|
cut.FindAll("thead th")[0].TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Enter" });
|
|
|
|
cut.WaitForAssertion(() => Assert.Equal("Alpha", FirstRowInstance(cut)));
|
|
// aria-sort now reflects the active ascending instance column.
|
|
Assert.Equal("ascending", cut.FindAll("thead th")[0].GetAttribute("aria-sort"));
|
|
}
|
|
|
|
private static string FirstRowInstance(IRenderedComponent<AlarmSummaryPage> cut) =>
|
|
cut.FindAll("tr[data-test='alarm-summary-row']")[0]
|
|
.QuerySelector("td")!.TextContent.Trim();
|
|
|
|
// ── live-cache-first with poll fallback ───────────────────
|
|
|
|
/// <summary>Verifies that selecting a site subscribes to the live cache.</summary>
|
|
[Fact]
|
|
public void SelectingSite_SubscribesToTheLiveCache()
|
|
{
|
|
RenderWithSiteSelected();
|
|
|
|
Assert.Equal(1, _liveCache.SubscribeCount);
|
|
Assert.Equal(1, _liveCache.LastSubscribedSite);
|
|
}
|
|
|
|
/// <summary>Verifies that the poll fallback renders when the live cache is not live yet.</summary>
|
|
[Fact]
|
|
public void PollFallbackRenders_WhenCacheNotLiveYet()
|
|
{
|
|
// Cache starts not-live, so the site select falls back to the poll's
|
|
// GetSiteAlarmsAsync snapshot (Zeta + Alpha).
|
|
Assert.False(_liveCache.IsLive(1));
|
|
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
Assert.Equal(2, cut.FindAll("tr[data-test='alarm-summary-row']").Count);
|
|
Assert.Equal("Zeta", FirstRowInstance(cut)); // severity-desc default
|
|
}
|
|
|
|
/// <summary>Verifies that a change notification rebuilds the rendered rows from the live snapshot.</summary>
|
|
[Fact]
|
|
public void OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
// Push a fresh live snapshot: a single new alarm on a new instance.
|
|
_liveCache.PushAlarms(new List<AlarmStateChanged>
|
|
{
|
|
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
|
|
});
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
var rows = cut.FindAll("tr[data-test='alarm-summary-row']");
|
|
Assert.Single(rows);
|
|
Assert.Equal("Gamma", FirstRowInstance(cut));
|
|
});
|
|
}
|
|
|
|
/// <summary>Verifies that leaving a site unsubscribes from the live cache.</summary>
|
|
[Fact]
|
|
public void LeavingSite_UnsubscribesFromTheLiveCache()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
Assert.Equal(0, _liveCache.DisposeCount);
|
|
|
|
// Clearing the site picker tears the subscription down.
|
|
cut.Find("[data-test='alarm-summary-site']").Change("");
|
|
|
|
Assert.Equal(1, _liveCache.DisposeCount);
|
|
}
|
|
|
|
/// <summary>Verifies that changing the site resubscribes to the new site.</summary>
|
|
[Fact]
|
|
public void ChangingSite_ResubscribesToTheNewSite()
|
|
{
|
|
var cut = RenderWithSiteSelected(); // site 1
|
|
|
|
cut.Find("[data-test='alarm-summary-site']").Change("2");
|
|
|
|
Assert.Equal(2, _liveCache.SubscribeCount); // one per site
|
|
Assert.Equal(1, _liveCache.DisposeCount); // old subscription disposed
|
|
Assert.Equal(2, _liveCache.LastSubscribedSite);
|
|
}
|
|
|
|
/// <summary>Verifies that disposing the component unsubscribes from the live cache.</summary>
|
|
[Fact]
|
|
public void DisposingComponent_UnsubscribesFromTheLiveCache()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
cut.Instance.Dispose();
|
|
|
|
Assert.True(_liveCache.DisposeCount >= 1);
|
|
}
|
|
|
|
// ── arch-review R2 N4: stale-site poll guard ───────────────────────────────
|
|
|
|
/// <summary>Verifies that an in-flight poll for the previous site does not overwrite the new site's rows.</summary>
|
|
[Fact]
|
|
public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
|
|
{
|
|
// Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4).
|
|
var site1Tcs = new TaskCompletionSource<AlarmSummaryResult>();
|
|
_summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
|
|
_summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
|
|
new AlarmSummaryResult(
|
|
new List<AlarmSummaryRow>
|
|
{
|
|
new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
|
},
|
|
Array.Empty<string>())));
|
|
|
|
var cut = Render<AlarmSummaryPage>();
|
|
cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung
|
|
cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
|
|
cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));
|
|
|
|
// The stale first-site fan-out now completes — it must be dropped, not rendered.
|
|
site1Tcs.SetResult(new AlarmSummaryResult(
|
|
new List<AlarmSummaryRow>
|
|
{
|
|
new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)),
|
|
},
|
|
new[] { "StaleNotReporting" }));
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Equal("Site2Instance", FirstRowInstance(cut));
|
|
Assert.DoesNotContain("StaleNotReporting", cut.Markup);
|
|
});
|
|
}
|
|
|
|
// ── arch-review R2 N5: while live, the poll updates only _notReporting ──────
|
|
|
|
/// <summary>Verifies that a poll while live updates only the not-reporting list and does not regress the live rows.</summary>
|
|
[Fact]
|
|
public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
|
|
{
|
|
// Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance.
|
|
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
|
|
new List<AlarmSummaryRow>
|
|
{
|
|
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
|
},
|
|
new[] { "OfflineInstance" })));
|
|
|
|
var cut = RenderWithSiteSelected();
|
|
|
|
// Go live with a fresher delta: Gamma only (arch-review R2 N5).
|
|
_liveCache.PushAlarms(new List<AlarmStateChanged>
|
|
{
|
|
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
|
|
});
|
|
cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));
|
|
|
|
// A poll now completes: it owns _notReporting but must NOT rebuild the rows.
|
|
cut.Find("[data-test='alarm-summary-refresh']").Click();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("OfflineInstance", cut.Markup); // notReporting refreshed
|
|
Assert.Equal("Gamma", FirstRowInstance(cut)); // live rows NOT regressed
|
|
Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']"));
|
|
});
|
|
}
|
|
|
|
// ── site scoping (a scoped Deployer must not watch another
|
|
// site's live alarms — the page was missed by the ad0100f2 scoping survey) ──
|
|
|
|
/// <summary>Verifies that a site-scoped deployment user sees only the permitted site in the picker.</summary>
|
|
[Fact]
|
|
public void SiteScoping_ScopedDeploymentUser_SeesOnlyPermittedSiteInPicker()
|
|
{
|
|
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
|
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Site 1 only
|
|
}, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
|
|
|
var cut = Render<AlarmSummaryPage>();
|
|
|
|
var options = cut.FindAll("[data-test='alarm-summary-site'] option");
|
|
// "Select site…" placeholder + Site 1 only — Site 2 must not appear.
|
|
Assert.Equal(2, options.Count);
|
|
Assert.DoesNotContain("Site 2", cut.Markup);
|
|
}
|
|
|
|
/// <summary>Verifies that a forged out-of-scope site id is denied and never fetched.</summary>
|
|
[Fact]
|
|
public void SiteScoping_ForgedOutOfScopeSiteId_IsDeniedNotFetched()
|
|
{
|
|
// The picker never renders Site 2 for this user, but the change handler
|
|
// must independently reject an out-of-scope id arriving via any onchange
|
|
// value (e.g. a forged one, since Blazor Server processes the SignalR
|
|
// event server-side) rather than trusting it because it came through the
|
|
// <select> element.
|
|
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
|
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Site 1 only
|
|
}, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
|
|
|
var cut = Render<AlarmSummaryPage>();
|
|
cut.Find("[data-test='alarm-summary-site']").Change("2"); // out-of-scope, forged
|
|
|
|
// Denied server-side: no alarm table renders, selection resets to empty.
|
|
Assert.Empty(cut.FindAll("tr[data-test='alarm-summary-row']"));
|
|
Assert.NotNull(cut.Find("[data-test='alarm-summary-empty']"));
|
|
_summary.DidNotReceive().GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── arch-review R2 N7: disposal guard on the live callback ─────────────────
|
|
|
|
/// <summary>Verifies that a live callback racing with disposal is dropped without a rebuild or an exception.</summary>
|
|
[Fact]
|
|
public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
|
|
{
|
|
var cut = RenderWithSiteSelected();
|
|
_liveCache.PushAlarms(new List<AlarmStateChanged>
|
|
{
|
|
new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
|
|
});
|
|
cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));
|
|
|
|
// Snapshot the callback BEFORE disposal — this models a delta thread that
|
|
// already read the delegate when Dispose ran (arch-review R2 N7).
|
|
var inFlight = _liveCache.CapturedCallback!;
|
|
cut.Instance.Dispose();
|
|
|
|
_liveCache.PushAlarms(new List<AlarmStateChanged>()); // mutate the fake's snapshot
|
|
var ex = Record.Exception(inFlight);
|
|
Assert.Null(ex);
|
|
// The disposed page must not have re-applied the (now empty) live snapshot.
|
|
Assert.Equal("Gamma", FirstRowInstance(cut));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Controllable in-memory fake of <see cref="ISiteAlarmLiveCache"/>: records
|
|
/// subscribe/dispose counts and lets a test push a live snapshot (which flips
|
|
/// <see cref="IsLive"/> true and fires the stored onChanged), so the page's
|
|
/// live-delta path can be driven deterministically.
|
|
/// </summary>
|
|
private sealed class FakeSiteAlarmLiveCache : ISiteAlarmLiveCache
|
|
{
|
|
private Action? _onChanged;
|
|
private IReadOnlyList<AlarmStateChanged> _current = Array.Empty<AlarmStateChanged>();
|
|
private bool _live;
|
|
|
|
/// <summary>Gets the number of subscribe calls received.</summary>
|
|
public int SubscribeCount { get; private set; }
|
|
/// <summary>Gets the number of subscriptions disposed.</summary>
|
|
public int DisposeCount { get; private set; }
|
|
/// <summary>Gets the site id of the most recent subscribe call.</summary>
|
|
public int? LastSubscribedSite { get; private set; }
|
|
/// <summary>Gets the change callback captured by the most recent subscribe call.</summary>
|
|
public Action? CapturedCallback => _onChanged;
|
|
|
|
/// <inheritdoc />
|
|
public IDisposable Subscribe(int siteId, Action onChanged)
|
|
{
|
|
SubscribeCount++;
|
|
LastSubscribedSite = siteId;
|
|
_onChanged = onChanged;
|
|
return new Subscription(this);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => _current;
|
|
|
|
/// <inheritdoc />
|
|
public bool IsLive(int siteId) => _live;
|
|
|
|
/// <summary>
|
|
/// The aggregator's own fan-out result. Settable so a test can prove the page renders
|
|
/// the not-reporting list sourced from the live cache while it is serving the site.
|
|
/// </summary>
|
|
public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;
|
|
|
|
/// <summary>Replaces the current snapshot, marks the cache live and raises the change callback.</summary>
|
|
/// <param name="alarms">The alarm snapshot to publish.</param>
|
|
public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms)
|
|
{
|
|
_current = alarms;
|
|
_live = true;
|
|
_onChanged?.Invoke();
|
|
}
|
|
|
|
private sealed class Subscription : IDisposable
|
|
{
|
|
private readonly FakeSiteAlarmLiveCache _owner;
|
|
private bool _disposed;
|
|
|
|
/// <summary>Initializes the subscription for the given fake cache.</summary>
|
|
/// <param name="owner">The fake cache that issued the subscription.</param>
|
|
public Subscription(FakeSiteAlarmLiveCache owner) => _owner = owner;
|
|
|
|
/// <summary>Releases the resources held by this instance.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_owner.DisposeCount++;
|
|
_owner._onChanged = null;
|
|
}
|
|
}
|
|
}
|
|
}
|