R4: Worker von WorkerEngine auf IHostedService (Generic Host) - WorkerBase implementiert IHostedService (StartAsync/StopAsync mit CancellationToken, eigener CTS, Guard auf Info.Active); IWorker auf Metadaten + TriggerAsync reduziert - Worker via AddHostedService registriert (Core + Modul); AppHost.Start() startet sie, AppHost.StopAsync() stoppt sie - WorkerEngine auf leichte Registry reduziert (WorkerInfos fuer UI + TriggerWorkerAsync) - LauncherForm startet/stoppt keine Worker mehr (Host-getrieben) - Hosting.Abstractions im Core; Tests angepasst -> 43/43 gruen; smoke-ui + App-Start ok Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
130 lines
4.6 KiB
C#
130 lines
4.6 KiB
C#
using System.Diagnostics;
|
||
using FluentAssertions;
|
||
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Persistence.Ef;
|
||
using IBKRTrader.Core.Workers;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace IBKRTrader.Tests.Workers;
|
||
|
||
[Trait("cat", "unit")]
|
||
public class WorkerBaseTests
|
||
{
|
||
/// <summary>
|
||
/// Testbarer Worker: überschreibt den DB-Log-Seam (kein MySQL-Zugriff) und
|
||
/// zählt seine Läufe. Zustände werden per Polling geprüft, weil WorkerBase
|
||
/// Info.Status intern (und nach dem Log-Abschluss) setzt.
|
||
/// </summary>
|
||
private sealed class TestWorker : WorkerBase
|
||
{
|
||
private readonly TimeSpan? _interval;
|
||
private readonly Func<CancellationToken, Task> _body;
|
||
|
||
public int Runs;
|
||
|
||
public override string Name => "TestWorker";
|
||
public override string Module => "TEST";
|
||
protected override TimeSpan? Interval => _interval;
|
||
|
||
private sealed class InMemoryFactory(DbContextOptions<CoreDbContext> o) : IDbContextFactory<CoreDbContext>
|
||
{
|
||
public CoreDbContext CreateDbContext() => new(o);
|
||
}
|
||
|
||
private static IDbContextFactory<CoreDbContext> Dbf() =>
|
||
new InMemoryFactory(new DbContextOptionsBuilder<CoreDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
|
||
|
||
public TestWorker(TimeSpan? interval, Func<CancellationToken, Task> body)
|
||
: base(new LoggingService(), Dbf())
|
||
{
|
||
_interval = interval;
|
||
_body = body;
|
||
}
|
||
|
||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||
{
|
||
Interlocked.Increment(ref Runs);
|
||
await _body(ct);
|
||
}
|
||
|
||
// Seam überschreiben → kein DB-Zugriff. Positive Id, damit die End-Log-Aufrufe
|
||
// (in WorkerBase mit `logId > 0` geschützt) auch im Fehlerpfad laufen.
|
||
protected override Task<long> BeginRunLogAsync() => Task.FromResult(1L);
|
||
protected override Task EndRunLogAsync(long logId, bool success, string? message = null)
|
||
=> Task.CompletedTask;
|
||
}
|
||
|
||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||
|
||
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||
{
|
||
var sw = Stopwatch.StartNew();
|
||
while (!condition())
|
||
{
|
||
if (sw.Elapsed > Timeout)
|
||
throw new TimeoutException($"Bedingung nicht innerhalb {Timeout.TotalSeconds}s erfüllt: {because}");
|
||
await Task.Delay(15);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RunsOnce_WhenIntervalIsNull_AndReportsIdle()
|
||
{
|
||
var worker = new TestWorker(interval: null, _ => Task.CompletedTask);
|
||
|
||
await worker.StartAsync(CancellationToken.None);
|
||
// Hinweis: WorkerBase startet bereits im Zustand Idle – deshalb auf den
|
||
// abgeschlossenen Lauf warten (Runs == 1 UND wieder Idle).
|
||
await WaitUntilAsync(
|
||
() => worker.Runs == 1 && worker.Info.Status == WorkerStatus.Idle,
|
||
"ein Lauf ist abgeschlossen und Status zurück auf Idle");
|
||
|
||
worker.Runs.Should().Be(1);
|
||
|
||
await worker.StopAsync(CancellationToken.None);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Trigger_ForcesImmediateRun_BeforeIntervalElapses()
|
||
{
|
||
// Langes Intervall → ein zweiter Lauf kann nur durch Trigger entstehen.
|
||
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
|
||
|
||
await worker.StartAsync(CancellationToken.None);
|
||
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
|
||
|
||
await worker.TriggerAsync();
|
||
await WaitUntilAsync(() => worker.Runs == 2, "Trigger löst zweiten Lauf aus");
|
||
|
||
await worker.StopAsync(CancellationToken.None);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Exception_SetsStatusError_AndCapturesMessage()
|
||
{
|
||
var worker = new TestWorker(interval: null,
|
||
_ => throw new InvalidOperationException("boom"));
|
||
|
||
await worker.StartAsync(CancellationToken.None);
|
||
await WaitUntilAsync(() => worker.Info.Status == WorkerStatus.Error, "Status wird Error");
|
||
|
||
worker.Runs.Should().Be(1);
|
||
worker.Info.Info.Should().Contain("boom");
|
||
|
||
await worker.StopAsync(CancellationToken.None);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Stop_SetsStatusStopped()
|
||
{
|
||
var worker = new TestWorker(TimeSpan.FromMinutes(10), _ => Task.CompletedTask);
|
||
|
||
await worker.StartAsync(CancellationToken.None);
|
||
await WaitUntilAsync(() => worker.Runs == 1, "erster Lauf erfolgt");
|
||
await worker.StopAsync(CancellationToken.None);
|
||
|
||
worker.Info.Status.Should().Be(WorkerStatus.Stopped);
|
||
}
|
||
}
|