using System.Net.Http.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; [Collection("Integration")] public class ExplainableAlertsTests : IAsyncLifetime { private readonly ApiFixture _fixture; public ExplainableAlertsTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() => await ExplainableAlertsTestHelper.ResetAsync(_fixture); public Task DisposeAsync() => Task.CompletedTask; [Fact] public async Task News2Alert_IncludesScoreContributors() { var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync( _fixture, respRate: 28, spo2: 92, systolicBp: 95, heartRate: 110, tempC: 38.5m, supplementalO2: 1m); var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync( _fixture, encounterId, AlertType.News2Emergency); var body = await ExplainableAlertsTestHelper.GetAlertAsync(_fixture, alert.Id); body.Explanation.Should().NotBeNull(); body.Explanation!.ScoreContributors.Should().HaveCount(7); body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Respiratory Rate" && c.Points > 0); body.Explanation.NarrativeSummary.Should().NotBeNullOrWhiteSpace(); } [Fact] public async Task SofaAlert_IncludesOrganContributors() { var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedSofaBaselineAsync(_fixture); await ExplainableAlertsTestHelper.AdvanceSofaDeltaAsync(_fixture, encounterId, patientId, delta: 2); var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync( _fixture, encounterId, AlertType.SofaSepsis); var body = await ExplainableAlertsTestHelper.GetAlertAsync(_fixture, alert.Id); body.Explanation.Should().NotBeNull(); body.Explanation!.ScoreContributors.Should().NotBeEmpty(); body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Respiratory"); } [Fact] public async Task GcsAlert_IncludesEyeVerbalMotor() { var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture); await ExplainableAlertsTestHelper.RecordGcsAsync( _fixture, encounterId, patientId, eye: 2, verbal: 2, motor: 3); var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync( _fixture, encounterId, AlertType.GcsCritical); var body = await ExplainableAlertsTestHelper.GetAlertAsync(_fixture, alert.Id); body.Explanation.Should().NotBeNull(); body.Explanation!.ScoreContributors.Should().HaveCount(3); body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Eye"); } [Fact] public async Task TrendAlert_IncludesTrendContext() { var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture); await ExplainableAlertsTestHelper.SimulateRapidRespRateRiseAsync( _fixture, encounterId, patientId); var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync( _fixture, encounterId, AlertType.RapidDeterioration); var body = await ExplainableAlertsTestHelper.GetAlertAsync(_fixture, alert.Id); body.Explanation.Should().NotBeNull(); body.Explanation!.Trend.Should().NotBeNull(); body.Explanation.Trend!.PercentChange.Should().BeGreaterThan(0); body.Explanation.Trend.Duration.Should().NotBe(default); } [Fact] public async Task MedicationAlert_IncludesDrugContext() { var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture); await ExplainableAlertsTestHelper.AdministerMedicationAsync( _fixture, encounterId, "acetaminophen", dose: 1000, unit: "mg"); await ExplainableAlertsTestHelper.FeedNews2VitalsAsync( _fixture, encounterId, patientId, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72, tempC: 39.2m, supplementalO2: 0m); var alert = await ExplainableAlertsTestHelper.WaitForFirstAlertWithExplanationAsync( _fixture, encounterId); alert.Explanation.Should().NotBeNull(); alert.Explanation!.MedicationContext.Should().NotBeNull(); alert.Explanation.MedicationContext!.DrugName.Should().BeEquivalentTo("acetaminophen"); } [Fact] public async Task NarrativeSummary_IsHumanReadable() { var alert = await ExplainableAlertsTestHelper.GetAnyAlertWithExplanationAsync(_fixture); alert.Explanation.Should().NotBeNull(); alert.Explanation!.NarrativeSummary.Should().MatchRegex("(NEWS2|SOFA|GCS|rapid deterioration)", "i"); } [Fact] public async Task LegacyAlert_NullExplanation_Serializes() { var legacyId = await ExplainableAlertsTestHelper.SeedLegacyAlertWithoutExplanationAsync(_fixture); var body = await ExplainableAlertsTestHelper.GetAlertAsync(_fixture, legacyId); body.Explanation.Should().BeNull(); } [Fact] public async Task ListByEncounter_ReturnsExplanation() { var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync( _fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72, tempC: 37.0m, supplementalO2: 0m); using var client = _fixture.CreateClient(); var response = await client.GetAsync($"/api/v1/encounters/{encounterId}/alerts"); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync>>( ExplainableAlertsTestHelper.JsonOptions); body!.Data!.Items.Should().NotBeEmpty(); body.Data.Items.Should().AllSatisfy(a => { a.Explanation.Should().NotBeNull(); a.Explanation!.NarrativeSummary.Should().NotBeNullOrWhiteSpace(); }); } [Fact] public async Task ScoringOutputs_Unchanged() { var before = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture); var after = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture); after.TotalScore.Should().Be(before.TotalScore); after.AlertType.Should().Be(before.AlertType); } [Fact] public async Task ExplainableAlert_OutboxPayload_IncludesExplanation() { var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync( _fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72, tempC: 37.0m, supplementalO2: 0m); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var outbox = await db.OutboxEvents .Where(e => e.Topic == "alert.generated" && e.PartitionKey == encounterId.ToString()) .OrderByDescending(e => e.CreatedAt) .FirstAsync(); outbox.Payload.Should().Contain("explanation"); outbox.Payload.Should().Contain("narrativeSummary"); } }