using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; [Collection("Integration")] public class News2DetectorTests : IAsyncLifetime { private readonly ApiFixture _fixture; private Guid _encounterId; private Guid _patientId; public News2DetectorTests(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-NEWS2-001", FirstName = "NEWS2", LastName = "Test", DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow }; var encounter = new Encounter { Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. NEWS2", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow }; db.Patients.Add(patient); db.Encounters.Add(encounter); await db.SaveChangesAsync(); _patientId = patient.Id; _encounterId = encounter.Id; var redis = scope.ServiceProvider.GetRequiredService(); var cache = redis.GetDatabase(); foreach (var key in News2Calculator.AllParameterKeys(_encounterId)) await cache.KeyDeleteAsync(key); } public Task DisposeAsync() => Task.CompletedTask; [Fact] public async Task AllNormalParameters_ScoreZero_NoAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 36.8m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "SUPPLEMENTAL_O2", 0m); result.Outcome.Should().Be(News2Outcome.ScoreComputed); result.TotalScore.Should().Be(0); result.RiskLevel.Should().Be("LOW"); result.AlertCreated.Should().BeFalse(); var db = scope.ServiceProvider.GetRequiredService(); var score = await db.News2Scores.SingleAsync(); score.TotalScore.Should().Be(0); score.RiskLevel.Should().Be("LOW"); (await db.ClinicalAlerts.CountAsync()).Should().Be(0); } [Fact] public async Task MediumRisk_WarningAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 22m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 93m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 105m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "SUPPLEMENTAL_O2", 0m); result.TotalScore.Should().Be(6); result.RiskLevel.Should().Be("MEDIUM"); result.AlertCreated.Should().BeTrue(); var db = scope.ServiceProvider.GetRequiredService(); var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.News2Warning); alert.Severity.Should().Be(AlertSeverity.Warning); } [Fact] public async Task HighRisk_CriticalAlert() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 25m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 91m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "SUPPLEMENTAL_O2", 0m); result.TotalScore.Should().Be(8); result.RiskLevel.Should().Be("HIGH"); result.AlertCreated.Should().BeTrue(); var db = scope.ServiceProvider.GetRequiredService(); var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.News2Emergency); alert.Severity.Should().Be(AlertSeverity.Critical); } [Fact] public async Task SingleParamThree_LowMedium_Warning() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 8m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 120m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "SUPPLEMENTAL_O2", 0m); result.TotalScore.Should().Be(3); result.RiskLevel.Should().Be("LOW_MEDIUM"); result.AlertCreated.Should().BeTrue(); result.HasSingleParamThree.Should().BeTrue(); var db = scope.ServiceProvider.GetRequiredService(); var score = await db.News2Scores.SingleAsync(); score.HasSingleParamThree.Should().BeTrue(); var alert = await db.ClinicalAlerts.SingleAsync(); alert.AlertType.Should().Be(AlertType.News2Warning); } [Fact] public async Task IncompleteParameters_NoScore() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 16m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 98m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); var result = await detector.ProcessObservationAsync( _encounterId, _patientId, "TEMP_C", 37.0m); result.Outcome.Should().Be(News2Outcome.IncompleteParameters); result.PresentParameters.Should().Be(4); var db = scope.ServiceProvider.GetRequiredService(); (await db.News2Scores.CountAsync()).Should().Be(0); (await db.ClinicalAlerts.CountAsync()).Should().Be(0); } [Fact] public async Task NonNews2Code_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); result.Outcome.Should().Be(News2Outcome.NotNews2Code); var anyNews2Key = false; foreach (var key in News2Calculator.AllParameterKeys(_encounterId)) { if (await redis.GetDatabase().KeyExistsAsync(key)) anyNews2Key = true; } anyNews2Key.Should().BeFalse(); } [Fact] public async Task DuplicateHighRisk_Idempotent() { using var scope = _fixture.Services.CreateScope(); var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); // First pass — score 8, creates alert await FeedHighRiskSet(detector); // Second scoring event — one parameter update while all 7 keys remain populated. // Each observation after completeness triggers a new score row; alert must stay deduplicated. await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); (await db.ClinicalAlerts.CountAsync()).Should().Be(1, "second NEWS2_EMERGENCY must be idempotent while first is still open"); (await db.News2Scores.CountAsync()).Should().Be(2, "both scores should be persisted even though alert is deduplicated"); } private async Task FeedHighRiskSet(News2Detector detector) { await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 25m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SPO2", 91m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 72m); await detector.ProcessObservationAsync(_encounterId, _patientId, "AVPU", 0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 37.0m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SUPPLEMENTAL_O2", 0m); } }