using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Integration")] public class TrendDetectorTests : IAsyncLifetime { private readonly ApiFixture _fixture; private Guid _encounterId; private Guid _patientId; private static readonly DateTimeOffset BaseTime = new(2026, 6, 18, 10, 0, 0, TimeSpan.Zero); public TrendDetectorTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); var patient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-TREND-001", FirstName = "Trend", LastName = "Test", DateOfBirth = new DateOnly(1970, 3, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Trend", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); _patientId = patient.Id; _encounterId = encounter.Id; await ClearTrendHistoryAsync(scope.ServiceProvider); } public Task DisposeAsync() => Task.CompletedTask; [Fact] public async Task RapidHeartRateClimb_CreatesAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 72m, BaseTime); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10)); result.Outcome.Should().Be(TrendOutcome.RapidDeterioration); result.AlertCreated.Should().BeTrue(); result.RatePerMinute.Should().Be(2.3m); var db = scope.ServiceProvider.GetRequiredService(); var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.RapidDeterioration); alert.Severity.Should().Be(AlertSeverity.Warning); alert.Details.Should().Contain("HEART_RATE"); } [Fact] public async Task StableHighHeartRate_NoTrendAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m, BaseTime); await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10)); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(20)); result.Outcome.Should().Be(TrendOutcome.Stable); result.AlertCreated.Should().BeFalse(); var db = scope.ServiceProvider.GetRequiredService(); (await db.ClinicalAlerts.CountAsync()).Should().Be(0); } [Fact] public async Task InsufficientHistory_NoAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 72m, BaseTime); result.Outcome.Should().Be(TrendOutcome.InsufficientHistory); result.AlertCreated.Should().BeFalse(); } [Fact] public async Task NonTrendCode_Ignored() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "POTASSIUM_MEQ_L", 4.0m, BaseTime); result.Outcome.Should().Be(TrendOutcome.NotTrendCode); var key = TrendCalculator.HistoryKey(_encounterId, "POTASSIUM_MEQ_L"); (await redis.GetDatabase().KeyExistsAsync(key)).Should().BeFalse(); } [Fact] public async Task DuplicateTrendAlert_Idempotent() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 72m, BaseTime); await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 95m, BaseTime.AddMinutes(10)); var second = await detector.ProcessObservationAsync( _encounterId, _patientId, "HEART_RATE", 98m, BaseTime.AddMinutes(20)); second.Outcome.Should().Be(TrendOutcome.AlertAlreadyOpen); second.AlertCreated.Should().BeFalse(); (await db.ClinicalAlerts.CountAsync()).Should().Be(1, "second rapid climb must not duplicate while first RAPID_DETERIORATION is open"); } private async Task ClearTrendHistoryAsync(IServiceProvider services) { var redis = services.GetRequiredService(); var cache = redis.GetDatabase(); foreach (var key in TrendCalculator.AllHistoryKeys(_encounterId)) await cache.KeyDeleteAsync(key); } }