feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public interface ISessionCatalog
|
||||
{
|
||||
IReadOnlyList<SessionPreset> ListSessions();
|
||||
SessionPreset? GetById(string sessionId);
|
||||
}
|
||||
|
||||
public sealed record SessionPreset(
|
||||
string Id,
|
||||
string Name,
|
||||
string Goal,
|
||||
int EstimatedMinutes,
|
||||
double DefaultSpeed,
|
||||
IReadOnlyList<string> Scenarios);
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>sessions.json</c> from the scenario directory (or its parent) and
|
||||
/// validates every referenced scenario id against <see cref="IScenarioCatalog"/>
|
||||
/// at construction — a typo fails loudly at startup.
|
||||
/// </summary>
|
||||
public sealed class SessionCatalog : ISessionCatalog
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<SessionPreset> _sessions;
|
||||
|
||||
public SessionCatalog(IOptions<SimulationOptions> options, IScenarioCatalog scenarios)
|
||||
{
|
||||
var directory = options.Value.ScenarioDirectory;
|
||||
var path = ResolveManifestPath(directory)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Simulation session manifest not found. Expected sessions.json in or beside " +
|
||||
$"ScenarioDirectory '{Path.GetFullPath(directory)}'.");
|
||||
|
||||
_sessions = LoadAndValidate(path, scenarios);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionPreset> ListSessions() => _sessions;
|
||||
|
||||
public SessionPreset? GetById(string sessionId) =>
|
||||
_sessions.FirstOrDefault(s =>
|
||||
string.Equals(s.Id, sessionId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
internal static string? ResolveManifestPath(string scenarioDirectory)
|
||||
{
|
||||
var inDir = Path.Combine(scenarioDirectory, "sessions.json");
|
||||
if (File.Exists(inDir))
|
||||
return inDir;
|
||||
|
||||
var parent = Directory.GetParent(Path.GetFullPath(scenarioDirectory))?.FullName;
|
||||
if (parent is not null)
|
||||
{
|
||||
var beside = Path.Combine(parent, "sessions.json");
|
||||
if (File.Exists(beside))
|
||||
return beside;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SessionPreset> LoadAndValidate(
|
||||
string path, IScenarioCatalog scenarios)
|
||||
{
|
||||
SessionsDocument doc;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
doc = JsonSerializer.Deserialize<SessionsDocument>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' deserialized to null.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not InvalidOperationException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to load simulation session manifest '{path}': {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (doc.Sessions is null || doc.Sessions.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' contains no sessions.");
|
||||
|
||||
var presets = new List<SessionPreset>(doc.Sessions.Count);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var entry in doc.Sessions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Id))
|
||||
throw new InvalidOperationException(
|
||||
$"Session manifest '{path}' has a session with an empty id.");
|
||||
|
||||
if (entry.Scenarios is null || entry.Scenarios.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Session '{entry.Id}' in '{path}' lists no scenarios.");
|
||||
|
||||
foreach (var scenarioId in entry.Scenarios)
|
||||
{
|
||||
if (scenarios.GetById(scenarioId) is null)
|
||||
missing.Add($"{entry.Id} → {scenarioId}");
|
||||
}
|
||||
|
||||
presets.Add(new SessionPreset(
|
||||
entry.Id,
|
||||
entry.Name ?? entry.Id,
|
||||
entry.Goal ?? string.Empty,
|
||||
entry.EstimatedMinutes,
|
||||
entry.DefaultSpeed <= 0 ? 60 : entry.DefaultSpeed,
|
||||
entry.Scenarios.AsReadOnly()));
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Session manifest references unknown scenario id(s): " +
|
||||
string.Join("; ", missing));
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
private sealed class SessionsDocument
|
||||
{
|
||||
public List<SessionEntry>? Sessions { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SessionEntry
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string? Name { get; set; }
|
||||
public string? Goal { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public double DefaultSpeed { get; set; }
|
||||
public List<string>? Scenarios { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user