264 lines
12 KiB
C#
264 lines
12 KiB
C#
using Health.Application.Medications;
|
|
using Health.Domain.Entities;
|
|
using Health.Domain.Enums;
|
|
using Health.Infrastructure.AI.AgentHandlers;
|
|
using System.Text.Json;
|
|
|
|
namespace Health.Tests;
|
|
|
|
public sealed class MedicationUpdateTests
|
|
{
|
|
[Fact]
|
|
public async Task Update_CanClearAnExistingEndDate()
|
|
{
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "阿司匹林",
|
|
StartDate = new DateOnly(2026, 7, 1),
|
|
EndDate = new DateOnly(2026, 7, 31),
|
|
};
|
|
var repository = new FakeMedicationRepository(medication);
|
|
var service = new MedicationService(repository);
|
|
|
|
var updated = await service.UpdateAsync(
|
|
medication.UserId,
|
|
medication.Id,
|
|
new MedicationPatchRequest(
|
|
null, null, null, null, null, null, null,
|
|
ClearEndDate: true),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(updated);
|
|
Assert.Null(medication.EndDate);
|
|
Assert.True(repository.Saved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "未来用药",
|
|
IsActive = true,
|
|
StartDate = today.AddDays(2),
|
|
EndDate = today.AddDays(10),
|
|
Frequency = MedicationFrequency.Daily,
|
|
TimeOfDay = [new TimeOnly(8, 0)],
|
|
};
|
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
|
|
|
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
|
|
|
|
var plan = Assert.Single(overview);
|
|
Assert.Equal("upcoming", plan.Phase);
|
|
Assert.False(plan.ScheduledOnDate);
|
|
Assert.Empty(plan.Doses);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "两顿药",
|
|
IsActive = true,
|
|
StartDate = today.AddDays(-1),
|
|
EndDate = today.AddDays(1),
|
|
Frequency = MedicationFrequency.TwiceDaily,
|
|
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
|
};
|
|
var log = new MedicationLog
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = medication.UserId,
|
|
MedicationId = medication.Id,
|
|
ScheduledTime = new TimeOnly(8, 0),
|
|
Status = MedicationLogStatus.Taken,
|
|
ConfirmedAt = DateTime.UtcNow,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
|
|
|
|
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
|
|
|
|
Assert.Equal(2, plan.Doses.Count);
|
|
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
|
|
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "阿司匹林",
|
|
IsActive = true,
|
|
StartDate = today,
|
|
Frequency = MedicationFrequency.Daily,
|
|
TimeOfDay = [new TimeOnly(8, 0)],
|
|
};
|
|
var repository = new FakeMedicationRepository(medication);
|
|
var service = new MedicationService(repository);
|
|
|
|
var result = await service.ConfirmDoseAsync(
|
|
medication.UserId,
|
|
medication.Id,
|
|
new TimeOnly(8, 15),
|
|
MedicationLogStatus.Taken,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result);
|
|
Assert.Empty(repository.AddedLogs);
|
|
}
|
|
|
|
[Fact]
|
|
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
|
|
{
|
|
using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}""");
|
|
|
|
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
|
|
|
Assert.Equal("创建用药计划前需要确认每次剂量", error);
|
|
}
|
|
|
|
[Fact]
|
|
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
|
|
{
|
|
using var arguments = JsonDocument.Parse("""
|
|
{
|
|
"action":"create",
|
|
"name":"阿司匹林",
|
|
"dosage":"100mg",
|
|
"frequency":"Daily",
|
|
"time_of_day":["08:00"],
|
|
"start_date":"2026-07-20",
|
|
"duration_days":30
|
|
}
|
|
""");
|
|
|
|
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
|
|
|
Assert.Null(error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "明天开始的药",
|
|
Dosage = "1片",
|
|
IsActive = true,
|
|
StartDate = today.AddDays(1),
|
|
Frequency = MedicationFrequency.Daily,
|
|
TimeOfDay = [new TimeOnly(8, 0)],
|
|
};
|
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
|
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
|
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
|
|
|
|
var upcoming = await MedicationAgentHandler.Execute(
|
|
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
|
var current = await MedicationAgentHandler.Execute(
|
|
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
|
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
|
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
|
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
|
|
|
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
|
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
|
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
|
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
|
|
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
|
{
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "测试药物",
|
|
IsActive = true,
|
|
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
|
Frequency = MedicationFrequency.Daily,
|
|
TimeOfDay = [new TimeOnly(8, 0)],
|
|
};
|
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
|
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
|
|
|
var result = await MedicationAgentHandler.Execute(
|
|
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
|
|
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
|
|
|
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
|
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
|
|
{
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
var medication = new Medication
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = Guid.NewGuid(),
|
|
Name = "已停用药物",
|
|
IsActive = false,
|
|
StartDate = today.AddDays(-10),
|
|
EndDate = today.AddDays(10),
|
|
Frequency = MedicationFrequency.Daily,
|
|
TimeOfDay = [new TimeOnly(8, 0)],
|
|
};
|
|
var service = new MedicationService(new FakeMedicationRepository(medication));
|
|
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
|
|
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
|
|
|
|
var inactive = await MedicationAgentHandler.Execute(
|
|
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
|
var ended = await MedicationAgentHandler.Execute(
|
|
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
|
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
|
|
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
|
|
|
|
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
|
|
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
|
|
}
|
|
|
|
private sealed class FakeMedicationRepository(
|
|
Medication medication,
|
|
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
|
|
{
|
|
public bool Saved { get; private set; }
|
|
public List<MedicationLog> AddedLogs { get; } = [];
|
|
|
|
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
|
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
|
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
|
|
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
|
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
|
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
|
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
|
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
|
public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
|
|
public void DeleteMedication(Medication value) { }
|
|
public void DeleteLog(MedicationLog log) { }
|
|
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
|
}
|
|
}
|