237 lines
8.9 KiB
C#
237 lines
8.9 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")]
|
|
[InlineData("Integumentary System", "Skin & Subcutaneous Tissue")]
|
|
[InlineData("Eye & Adnexa", "Nervous System & Special Senses")]
|
|
[InlineData("Toxicology", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
|
|
{
|
|
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
|
var simulations = new[]
|
|
{
|
|
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
|
|
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
|
|
};
|
|
|
|
var score = DashboardStore.SelectLatestReadinessScore(simulations);
|
|
|
|
Assert.NotNull(score);
|
|
Assert.Equal(0.12, score.PassProbability);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
|
|
{
|
|
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
|
|
|
|
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
|
|
}
|
|
|
|
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
|
|
{
|
|
IsSample = isSample,
|
|
CompletedUtc = completedUtc,
|
|
Score = new ExamSimulationScore { PassProbability = passProbability }
|
|
};
|
|
}
|