Compare commits
4 Commits
v2-release
...
phase-6-4-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1cab33e38 | ||
| 0c903ff4e0 | |||
|
|
c4a92f424a | ||
| 510e488ea4 |
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification
|
||||
/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the
|
||||
/// <see cref="Equipment"/> row and emits one property per non-null field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Pure-function shape — testable without a real OPC UA node manager. The caller
|
||||
/// passes the builder scoped to the Equipment node; this class handles the Identification
|
||||
/// sub-folder creation + per-field <see cref="IAddressSpaceBuilder.AddProperty"/> calls.</para>
|
||||
///
|
||||
/// <para>ACL binding: the sub-folder + its properties inherit the Equipment scope's
|
||||
/// grants (no new scope level). Phase 6.2's trie treats them as part of the Equipment
|
||||
/// ScopeId — a user with Equipment-level grant reads Identification; a user without the
|
||||
/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.
|
||||
/// See <c>docs/v2/acl-design.md</c> §Identification cross-reference.</para>
|
||||
///
|
||||
/// <para>The nine fields per decision #139 are exposed exactly when they carry a non-null
|
||||
/// value. A row with all nine null produces no Identification sub-folder at all — the
|
||||
/// caller can use <see cref="HasAnyFields(Equipment)"/> to skip the Folder call entirely
|
||||
/// and avoid a pointless empty folder appearing in browse trees.</para>
|
||||
/// </remarks>
|
||||
public static class IdentificationFolderBuilder
|
||||
{
|
||||
/// <summary>Browse + display name of the sub-folder — fixed per OPC 40010 convention.</summary>
|
||||
public const string FolderName = "Identification";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
|
||||
/// matches the decision-log entry so any browse-order reader can cross-reference
|
||||
/// without re-sorting.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> FieldNames { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
/// <summary>True when the equipment row has at least one non-null Identification field.</summary>
|
||||
public static bool HasAnyFields(Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
return equipment.Manufacturer is not null
|
||||
|| equipment.Model is not null
|
||||
|| equipment.SerialNumber is not null
|
||||
|| equipment.HardwareRevision is not null
|
||||
|| equipment.SoftwareRevision is not null
|
||||
|| equipment.YearOfConstruction is not null
|
||||
|| equipment.AssetLocation is not null
|
||||
|| equipment.ManufacturerUri is not null
|
||||
|| equipment.DeviceManualUri is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the Identification sub-folder under <paramref name="equipmentBuilder"/>. No-op
|
||||
/// when every field is null. Returns the sub-folder builder (or null when no-op) so
|
||||
/// callers can attach additional nodes underneath if needed.
|
||||
/// </summary>
|
||||
public static IAddressSpaceBuilder? Build(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipmentBuilder);
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
|
||||
if (!HasAnyFields(equipment)) return null;
|
||||
|
||||
var folder = equipmentBuilder.Folder(FolderName, FolderName);
|
||||
AddIfPresent(folder, "Manufacturer", DriverDataType.String, equipment.Manufacturer);
|
||||
AddIfPresent(folder, "Model", DriverDataType.String, equipment.Model);
|
||||
AddIfPresent(folder, "SerialNumber", DriverDataType.String, equipment.SerialNumber);
|
||||
AddIfPresent(folder, "HardwareRevision", DriverDataType.String, equipment.HardwareRevision);
|
||||
AddIfPresent(folder, "SoftwareRevision", DriverDataType.String, equipment.SoftwareRevision);
|
||||
AddIfPresent(folder, "YearOfConstruction", DriverDataType.Int32,
|
||||
equipment.YearOfConstruction is null ? null : (object)(int)equipment.YearOfConstruction.Value);
|
||||
AddIfPresent(folder, "AssetLocation", DriverDataType.String, equipment.AssetLocation);
|
||||
AddIfPresent(folder, "ManufacturerUri", DriverDataType.String, equipment.ManufacturerUri);
|
||||
AddIfPresent(folder, "DeviceManualUri", DriverDataType.String, equipment.DeviceManualUri);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static void AddIfPresent(IAddressSpaceBuilder folder, string name, DriverDataType dataType, object? value)
|
||||
{
|
||||
if (value is null) return;
|
||||
folder.AddProperty(name, dataType, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Drives one or more <see cref="ScheduledRecycleScheduler"/> instances on a fixed tick
|
||||
/// cadence. Closes Phase 6.1 Stream B.4 by turning the shipped-as-pure-logic scheduler
|
||||
/// into a running background feature.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Registered as a singleton in Program.cs. Each Tier C driver instance that wants a
|
||||
/// scheduled recycle registers its scheduler via
|
||||
/// <see cref="AddScheduler(ScheduledRecycleScheduler)"/> at startup. The hosted service
|
||||
/// wakes every <see cref="TickInterval"/> (default 1 min) and calls
|
||||
/// <see cref="ScheduledRecycleScheduler.TickAsync"/> on each registered scheduler.</para>
|
||||
///
|
||||
/// <para>Scheduler registration is closed after <see cref="ExecuteAsync"/> starts — callers
|
||||
/// must register before the host starts, typically during DI setup. Adding a scheduler
|
||||
/// mid-flight throws to avoid confusing "some ticks saw my scheduler, some didn't" races.</para>
|
||||
/// </remarks>
|
||||
public sealed class ScheduledRecycleHostedService : BackgroundService
|
||||
{
|
||||
private readonly List<ScheduledRecycleScheduler> _schedulers = [];
|
||||
private readonly ILogger<ScheduledRecycleHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>How often <see cref="ScheduledRecycleScheduler.TickAsync"/> fires on each registered scheduler.</summary>
|
||||
public TimeSpan TickInterval { get; }
|
||||
|
||||
public ScheduledRecycleHostedService(
|
||||
ILogger<ScheduledRecycleHostedService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? tickInterval = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
TickInterval = tickInterval ?? TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
/// <summary>Register a scheduler to drive. Must be called before the host starts.</summary>
|
||||
public void AddScheduler(ScheduledRecycleScheduler scheduler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scheduler);
|
||||
if (_started)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot register a ScheduledRecycleScheduler after the hosted service has started. " +
|
||||
"Register all schedulers during DI configuration / startup.");
|
||||
_schedulers.Add(scheduler);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the current tick count — diagnostics only.</summary>
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
/// <summary>Snapshot of the number of registered schedulers — diagnostics only.</summary>
|
||||
public int SchedulerCount => _schedulers.Count;
|
||||
|
||||
public override Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_started = true;
|
||||
return base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ScheduledRecycleHostedService starting — {Count} scheduler(s), tick interval = {Interval}",
|
||||
_schedulers.Count, TickInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await TickOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ScheduledRecycleHostedService stopping after {TickCount} tick(s).", TickCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute one scheduler tick against every registered scheduler. Factored out of the
|
||||
/// <see cref="ExecuteAsync"/> loop so tests can drive it directly without needing to
|
||||
/// synchronize with <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
public async Task TickOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
TickCount++;
|
||||
|
||||
foreach (var scheduler in _schedulers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fired = await scheduler.TickAsync(now, cancellationToken).ConfigureAwait(false);
|
||||
if (fired)
|
||||
_logger.LogInformation("Scheduled recycle fired at {Now:o}; next = {Next:o}",
|
||||
now, scheduler.NextRecycleUtc);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A single scheduler fault must not take down the rest — log + continue.
|
||||
_logger.LogError(ex,
|
||||
"ScheduledRecycleScheduler tick failed at {Now:o}; continuing to other schedulers.", now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdentificationFolderBuilderTests
|
||||
{
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this; // flat recording — identification fields land in the same bucket
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException("Identification fields use AddProperty, not Variable");
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> Properties.Add((browseName, dataType, value));
|
||||
}
|
||||
|
||||
private static Equipment EmptyEquipment() => new()
|
||||
{
|
||||
EquipmentId = "EQ-000000000001",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "eq-1",
|
||||
MachineCode = "machine_001",
|
||||
};
|
||||
|
||||
private static Equipment FullyPopulatedEquipment() => new()
|
||||
{
|
||||
EquipmentId = "EQ-000000000001",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "eq-1",
|
||||
MachineCode = "machine_001",
|
||||
Manufacturer = "Siemens",
|
||||
Model = "S7-1500",
|
||||
SerialNumber = "SN-12345",
|
||||
HardwareRevision = "Rev-A",
|
||||
SoftwareRevision = "Fw-2.3.1",
|
||||
YearOfConstruction = 2023,
|
||||
AssetLocation = "Warsaw-West/Bldg-3",
|
||||
ManufacturerUri = "https://siemens.example",
|
||||
DeviceManualUri = "https://siemens.example/manual",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFields_AllNull_ReturnsFalse()
|
||||
{
|
||||
IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFields_OneNonNull_ReturnsTrue()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.SerialNumber = "SN-1";
|
||||
IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
var result = IdentificationFolderBuilder.Build(builder, EmptyEquipment());
|
||||
|
||||
result.ShouldBeNull();
|
||||
builder.Folders.ShouldBeEmpty("no Identification folder when every field is null");
|
||||
builder.Properties.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FullyPopulated_EmitsAllNineFields()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
var result = IdentificationFolderBuilder.Build(builder, FullyPopulatedEquipment());
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Identification");
|
||||
builder.Properties.Count.ShouldBe(9);
|
||||
builder.Properties.Select(p => p.BrowseName).ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri"],
|
||||
"property order matches decision #139 exactly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OnlyNonNull_Are_Emitted()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.Manufacturer = "Siemens";
|
||||
eq.SerialNumber = "SN-1";
|
||||
eq.YearOfConstruction = 2024;
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
builder.Properties.Count.ShouldBe(3, "only the 3 non-null fields are exposed");
|
||||
builder.Properties.Select(p => p.BrowseName).ShouldBe(
|
||||
["Manufacturer", "SerialNumber", "YearOfConstruction"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.YearOfConstruction = 2023;
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
var prop = builder.Properties.Single(p => p.BrowseName == "YearOfConstruction");
|
||||
prop.DataType.ShouldBe(DriverDataType.Int32);
|
||||
prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_StringValues_RoundTrip()
|
||||
{
|
||||
var eq = FullyPopulatedEquipment();
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
builder.Properties.Single(p => p.BrowseName == "Manufacturer").Value.ShouldBe("Siemens");
|
||||
builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldNames_Match_Decision139_Exactly()
|
||||
{
|
||||
IdentificationFolderBuilder.FieldNames.ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FolderName_Is_Identification()
|
||||
{
|
||||
IdentificationFolderBuilder.FolderName.ShouldBe("Identification");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScheduledRecycleHostedServiceTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class FakeClock : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "tier-c-fake";
|
||||
public int RecycleCount { get; private set; }
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "tier-c-throws";
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
=> throw new InvalidOperationException("supervisor unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_BeforeInterval_DoesNotFire()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var supervisor = new FakeSupervisor();
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(scheduler);
|
||||
|
||||
clock.Utc = T0.AddMinutes(1);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
host.TickCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_AfterInterval_Fires()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var supervisor = new FakeSupervisor();
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(scheduler);
|
||||
|
||||
clock.Utc = T0.AddMinutes(6);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_MultipleTicks_AccumulateCount()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddScheduler_AfterStart_Throws()
|
||||
{
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await host.StartAsync(cts.Token); // flips _started true even with cancelled token
|
||||
await host.StopAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, new FakeSupervisor(),
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => host.AddScheduler(scheduler));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneSchedulerThrowing_DoesNotStopOthers()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var good = new FakeSupervisor();
|
||||
var bad = new ThrowingSupervisor();
|
||||
|
||||
var goodSch = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, good,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
var badSch = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, bad,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(badSch);
|
||||
host.AddScheduler(goodSch);
|
||||
|
||||
clock.Utc = T0.AddMinutes(6);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
good.RecycleCount.ShouldBe(1, "a faulting scheduler must not poison its neighbours");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchedulerCount_MatchesAdded()
|
||||
{
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
|
||||
var sup = new FakeSupervisor();
|
||||
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(10), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
|
||||
host.SchedulerCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyScheduler_List_TicksCleanly()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
|
||||
// No registered schedulers — tick is a no-op + counter still advances.
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user