Redesign exam and CCS experiences

This commit is contained in:
hz
2026-07-16 22:42:03 -05:00
parent c8bfc26645
commit a778a680e0
35 changed files with 2132 additions and 274 deletions
+17 -1
View File
@@ -85,16 +85,32 @@ public sealed class ExamSimulationTests
State = ExamSimulationStates.Completed,
IsSample = true,
Items = BuildItems(20),
Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, Correct = 15, Total = 20 }
Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, PassProbability = 0.73, Correct = 15, Total = 20 }
});
Assert.Null(active.PredictedScore);
Assert.Equal(3, active.CurrentBlock);
Assert.Equal(221, completed.PredictedScore);
Assert.Equal(15, completed.Correct);
Assert.Equal(0.73, completed.PassProbability);
Assert.True(completed.IsSample);
}
[Fact]
public void HistoryCursorRoundTripsTimestampAndObjectId()
{
var timestamp = new DateTime(2026, 7, 16, 22, 15, 30, DateTimeKind.Utc);
const string id = "64b1d9f36f7a4e2b8c901234";
var encoded = ExamSimulationStore.EncodeHistoryCursor(timestamp, id);
Assert.True(ExamSimulationStore.TryDecodeHistoryCursor(encoded, out var decoded));
Assert.NotNull(decoded);
Assert.Equal(timestamp, decoded.CreatedUtc);
Assert.Equal(id, decoded.Id);
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
}
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{
@@ -51,27 +51,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
[HttpGet("completed")]
public async Task<IActionResult> Completed([FromQuery] int limit = 20, [FromQuery] string? before = null)
{
DateTime? cursor = null;
DateTime? completedBefore = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest("The completed-simulation cursor is invalid.");
cursor = parsed;
completedBefore = parsed;
}
return Ok(await simulations.GetCompletedAsync(UserId(), limit, cursor));
return Ok(await simulations.GetCompletedAsync(UserId(), limit, completedBefore));
}
[HttpGet("history")]
public async Task<IActionResult> History([FromQuery] int limit = 20, [FromQuery] string? before = null)
public async Task<IActionResult> History(
[FromQuery] int limit = 20,
[FromQuery] string status = "all",
[FromQuery] string sort = "newest",
[FromQuery] string? cursor = null,
[FromQuery] string? before = null)
{
DateTime? cursor = null;
DateTime? legacyBefore = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest("The simulation-history cursor is invalid.");
cursor = parsed;
legacyBefore = parsed;
}
return Ok(await simulations.GetHistoryAsync(UserId(), limit, cursor));
try { return Ok(await simulations.GetHistoryAsync(UserId(), limit, status, sort, cursor, legacyBefore)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
}
[HttpGet("{id}")]
@@ -104,6 +104,8 @@ public class ExamSimulationStateResponse
public int TotalBlocks { get; set; } = 12;
public int BreakSecondsRemaining { get; set; }
public DateTime? BlockDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public List<ExamSimulationQuestionResponse> Questions { get; set; } = [];
public List<ExamSimulationResponse> Responses { get; set; } = [];
public ExamSimulationScore? Score { get; set; }
@@ -151,6 +153,7 @@ public class ExamSimulationHistorySummary
public int? PredictedScore { get; set; }
public int? RangeLow { get; set; }
public int? RangeHigh { get; set; }
public double? PassProbability { get; set; }
public int? Correct { get; set; }
public int Total { get; set; }
}
@@ -159,6 +162,7 @@ public class ExamSimulationHistoryPage
{
public List<ExamSimulationHistorySummary> Items { get; set; } = [];
public DateTime? NextBefore { get; set; }
public string? NextCursor { get; set; }
}
public class Step3Outcome
@@ -1,5 +1,6 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Text;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
@@ -37,6 +38,8 @@ public class ExamSimulationStore
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Descending(item => item.CreatedUtc)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CreatedUtc).Descending(item => item.Id)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
@@ -200,22 +203,91 @@ public class ExamSimulationStore
};
}
public async Task<ExamSimulationHistoryPage> GetHistoryAsync(string userId, int limit, DateTime? before)
public async Task<ExamSimulationHistoryPage> GetHistoryAsync(
string userId,
int limit,
string status = "all",
string sort = "newest",
string? cursor = null,
DateTime? legacyBefore = null)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.UserId, userId);
if (before is not null)
filter &= Builders<ExamSimulation>.Filter.Lt(item => item.CreatedUtc, before.Value);
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CreatedUtc).Limit(limit + 1).ToListAsync();
status = status.Trim().ToLowerInvariant();
sort = sort.Trim().ToLowerInvariant();
if (status is not ("all" or "completed" or "abandoned"))
throw new ArgumentException("The simulation-history status is invalid.");
if (sort is not ("newest" or "oldest"))
throw new ArgumentException("The simulation-history sort order is invalid.");
var filters = Builders<ExamSimulation>.Filter;
var filter = filters.Eq(item => item.UserId, userId);
filter &= status switch
{
"completed" => filters.Eq(item => item.State, ExamSimulationStates.Completed),
"abandoned" => filters.Eq(item => item.State, ExamSimulationStates.Abandoned),
_ => filters.In(item => item.State, [ExamSimulationStates.Completed, ExamSimulationStates.Abandoned])
};
HistoryCursor? decodedCursor = null;
if (!string.IsNullOrWhiteSpace(cursor))
{
if (!TryDecodeHistoryCursor(cursor, out decodedCursor))
throw new ArgumentException("The simulation-history cursor is invalid.");
var dateComparison = sort == "newest"
? filters.Lt(item => item.CreatedUtc, decodedCursor!.CreatedUtc)
: filters.Gt(item => item.CreatedUtc, decodedCursor!.CreatedUtc);
var idComparison = sort == "newest"
? filters.Lt(item => item.Id, decodedCursor!.Id)
: filters.Gt(item => item.Id, decodedCursor!.Id);
filter &= dateComparison | (filters.Eq(item => item.CreatedUtc, decodedCursor!.CreatedUtc) & idComparison);
}
else if (legacyBefore is not null)
{
filter &= filters.Lt(item => item.CreatedUtc, legacyBefore.Value);
}
var sortDefinition = sort == "newest"
? Builders<ExamSimulation>.Sort.Descending(item => item.CreatedUtc).Descending(item => item.Id)
: Builders<ExamSimulation>.Sort.Ascending(item => item.CreatedUtc).Ascending(item => item.Id);
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
var last = simulations.LastOrDefault();
return new ExamSimulationHistoryPage
{
Items = simulations.Select(ToHistorySummary).ToList(),
NextBefore = hasMore && simulations.Count > 0 ? simulations[^1].CreatedUtc : null
NextBefore = sort == "newest" && hasMore && last is not null ? last.CreatedUtc : null,
NextCursor = hasMore && last is not null ? EncodeHistoryCursor(last.CreatedUtc, last.Id ?? "") : null
};
}
public static string EncodeHistoryCursor(DateTime createdUtc, string id) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createdUtc.ToUniversalTime().Ticks}|{id}"));
public static bool TryDecodeHistoryCursor(string value, out HistoryCursor? cursor)
{
cursor = null;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value));
var parts = decoded.Split('|', 2);
if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !ObjectId.TryParse(parts[1], out _))
return false;
cursor = new HistoryCursor(new DateTime(ticks, DateTimeKind.Utc), parts[1]);
return true;
}
catch (FormatException)
{
return false;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
public sealed record HistoryCursor(DateTime CreatedUtc, string Id);
public static ExamSimulationHistorySummary ToHistorySummary(ExamSimulation item) => new()
{
Id = item.Id ?? "",
@@ -228,6 +300,7 @@ public class ExamSimulationStore
PredictedScore = item.Score?.PredictedScore,
RangeLow = item.Score?.RangeLow,
RangeHigh = item.Score?.RangeHigh,
PassProbability = item.Score?.PassProbability,
Correct = item.Score?.Correct,
Total = item.Score?.Total ?? item.Items.Count
};
@@ -306,6 +379,8 @@ public class ExamSimulationStore
CurrentBlock = simulation.CurrentBlock,
BreakSecondsRemaining = simulation.BreakSecondsRemaining,
BlockDeadlineUtc = simulation.BlockDeadlineUtc,
CreatedUtc = simulation.CreatedUtc,
UpdatedUtc = simulation.UpdatedUtc,
Responses = simulation.Responses,
Score = simulation.Score,
BlueprintTargets = simulation.BlueprintTargets,