Files
step3/api/QuestionsApi.Tests/DashboardTests.cs
T
hz 34d283e213 Add rating snapshots, percentile, and exam readiness to the dashboard API
Backend half of the dashboard stat-card redesign. Three new data
points the old DashboardResponse had no way to compute:

- Week-over-week rating deltas: UserRating/CcsUserRating only ever
  stored the current Elo (mutated in place, UpdatedUtc just means
  "last touched"), so there was no way to know what a rating was 7
  days ago. Added UserRatingSnapshot (new collection, unique on
  UserId+DateUtc) and UserRatingSnapshotService, a BackgroundService
  following AnswerStatisticsMaintenanceService's exact shape (a
  PeriodicTimer plus a Mongo lease so replicas behind the HPA don't
  race), writing one snapshot per user per day. No historical
  backfill - deltas are simply null for a user's first ~7 days after
  this ships, which the DTO represents honestly via nullable ints
  rather than a fake "+0".
- Percentile rank: DashboardStore now counts how many users have a
  higher OverallElo/Elo than the current user out of the total.
- Exam Readiness + projected pass rate: reuses the existing, tested
  IRT-based ExamSimulationScore.PassProbability (a real normal-CDF
  estimate of scoring >=200, computed in ExamSimulationScoring.Calculate)
  rather than inventing a new prediction model. DashboardStore pulls
  the latest completed simulation's score directly and maps
  PassProbability to a High/Medium/Low label via tunable thresholds.
- Average Score's "+6% vs last 100" needed no new data source at all -
  just one more slice (Skip(100).Take(100)) over the QuestionAttempts
  already loaded for FirstAttemptAccuracy.

Added DashboardStore.Percentile/ReadinessLabel as pure static helpers
(matching the existing Percentage/CalculateCurrentStreak pattern) and
unit tests for both. Full backend suite: 165 passed, 0 failed.
2026-07-26 00:39:11 -05:00

203 lines
7.5 KiB
C#

using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DashboardTests
{
[Fact]
public void PercentageRoundsToNearestWholeNumber()
{
Assert.Equal(67, DashboardStore.Percentage(2, 3));
Assert.Equal(0, DashboardStore.Percentage(0, 0));
}
[Fact]
public void StreakIncludesYesterdayWhenUserHasNotStudiedToday()
{
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
var dates = new[] { today.AddDays(-1), today.AddDays(-2), today.AddDays(-3), today.AddDays(-5) };
Assert.Equal(3, DashboardStore.CalculateCurrentStreak(dates, today));
}
[Fact]
public void StreakStopsAtFirstMissingDay()
{
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
}
[Fact]
public void SystemPerformanceReturnsEveryOfficialSystemInOrder()
{
var attempts = new[]
{
Attempt("cardio-1", true),
Attempt("cardio-2", false),
Attempt("ethics-1", true),
Attempt("ignored", true),
Attempt("skipped-cardio", false, true)
};
var questions = new Dictionary<string, Question>
{
["cardio-1"] = Question("Cardiology"),
["cardio-2"] = Question("Cardiovascular System"),
["ethics-1"] = Question("Medical Ethics"),
["ignored"] = Question("Unmapped Specialty"),
["skipped-cardio"] = Question("Cardiology")
};
var result = DashboardStore.BuildSystemPerformance(attempts, questions);
Assert.Equal(14, result.Count);
Assert.Equal(DashboardStore.Step3Systems, result.Select(value => value.System));
var cardiovascular = result.Single(value => value.System == "Cardiovascular System");
Assert.Equal(1, cardiovascular.Correct);
Assert.Equal(2, cardiovascular.Total);
Assert.Equal(50, cardiovascular.Accuracy);
var social = result.Single(value => value.System.StartsWith("Social Sciences"));
Assert.Equal(1, social.Correct);
Assert.Equal(1, social.Total);
Assert.All(result.Where(value => value.System != cardiovascular.System && value.System != social.System),
value => Assert.Equal(0, value.Total));
}
[Theory]
[InlineData("Preventive Medicine", "Human Development")]
[InlineData("Infectious Disease", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
[InlineData("Dermatology", "Skin & Subcutaneous Tissue")]
[InlineData("Pulmonary", "Respiratory System")]
[InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")]
public void SystemAliasesMapToOfficialCategories(string source, string expected)
{
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
}
[Fact]
public void TrendCarriesRatingForwardAndLeavesInactiveAccuracyAsGapData()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var attempts = new[]
{
Attempt("one", true, createdUtc: today.AddDays(-2).AddHours(9),
rating: 1018, ratingUtc: today.AddDays(-2).AddHours(9)),
Attempt("two", false, createdUtc: today.AddDays(-2).AddHours(10),
rating: 1002, ratingUtc: today.AddDays(-2).AddHours(10)),
Attempt("three", true, createdUtc: today.AddHours(11),
rating: 1020, ratingUtc: today.AddHours(11)),
Attempt("skip", false, true, today.AddDays(-1).AddHours(4),
990, today.AddDays(-1).AddHours(4))
};
var rating = new UserRating
{
OverallElo = 1020,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var result = DashboardStore.BuildPerformanceTrend(
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
Assert.Equal(30, result.Count);
Assert.Null(result[^4].QuestionBankRating);
Assert.Equal(1002, result[^3].QuestionBankRating);
Assert.Equal(50, result[^3].Accuracy);
Assert.Equal(2, result[^3].Total);
Assert.Equal(990, result[^2].QuestionBankRating);
Assert.Equal(0, result[^2].Total);
Assert.Equal(1020, result[^1].QuestionBankRating);
Assert.Equal(100, result[^1].Accuracy);
}
[Fact]
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var rating = new UserRating();
var ccsRating = new CcsUserRating
{
Elo = 1050,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var ccsAttempts = new[]
{
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
RatedCcsAttempt("two", today.AddHours(10), 1050)
};
var result = DashboardStore.BuildPerformanceTrend(
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
Assert.Equal(30, result.Count);
Assert.Equal(1000, result[^3].CcsRating);
Assert.Equal(1030, result[^2].CcsRating);
Assert.Equal(1050, result[^1].CcsRating);
}
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
{
CaseExternalId = caseExternalId,
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
};
private static QuestionAttempt Attempt(
string questionId,
bool correct,
bool skip = false,
DateTime? createdUtc = null,
int? rating = null,
DateTime? ratingUtc = null) => new()
{
QuestionExternalId = questionId,
IsCorrect = correct,
IsSkip = skip,
CreatedUtc = createdUtc ?? DateTime.UtcNow,
UserOverallEloAfter = rating,
RatingAppliedUtc = ratingUtc
};
private static Question Question(string system) => new()
{
ExternalId = system,
System = system
};
[Fact]
public void PercentileReturnsNullWithNoUsers()
{
Assert.Null(DashboardStore.Percentile(0, 0));
}
[Fact]
public void PercentileComputesTopPercentFromUsersRatedHigher()
{
// 18 of 100 users rate higher than this user - "top 18%"
Assert.Equal(18, DashboardStore.Percentile(18, 100));
}
[Fact]
public void PercentileNeverReturnsZero()
{
// Even the single best-rated user should read as "top 1%", not "top 0%"
Assert.Equal(1, DashboardStore.Percentile(0, 100));
}
[Theory]
[InlineData(0.85, "High")]
[InlineData(0.7, "High")]
[InlineData(0.55, "Medium")]
[InlineData(0.4, "Medium")]
[InlineData(0.1, "Low")]
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
{
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
}
}