feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
|
||||
Assert.True(repository.Saved);
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||
[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<IReadOnlyList<MedicationLog>>([]);
|
||||
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> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(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) => 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; }
|
||||
|
||||
Reference in New Issue
Block a user