Add SUPER exam results preview

This commit is contained in:
hz
2026-07-14 17:25:25 -05:00
parent 51ce9f2e88
commit bbb373acf4
4 changed files with 109 additions and 0 deletions
@@ -25,6 +25,14 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
catch (InvalidOperationException exception) { return Conflict(exception.Message); } catch (InvalidOperationException exception) { return Conflict(exception.Message); }
} }
[HttpGet("preview-results")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> PreviewResults()
{
try { return Ok(await simulations.BuildResultsPreviewAsync(UserId())); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpGet("active")] [HttpGet("active")]
public async Task<IActionResult> Active() public async Task<IActionResult> Active()
{ {
@@ -93,6 +93,54 @@ public class ExamSimulationStore
return simulation; return simulation;
} }
public async Task<ExamSimulationStateResponse> BuildResultsPreviewAsync(string userId)
{
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
var targets = BuildTargets();
var selected = SelectForm(bank, [], targets);
var items = selected.Select((question, index) => new ExamSimulationItem
{
QuestionExternalId = question.ExternalId,
BlockIndex = BlockIndexForPosition(index),
Position = PositionWithinBlock(index),
PresentedChoiceIds = SelectChoices(question),
QuestionElo = 1000,
WasSeen = false,
BlueprintCategory = BlueprintCategory(question)
}).ToList();
var questionMap = selected.ToDictionary(question => question.ExternalId, StringComparer.Ordinal);
var responses = items.Select((item, index) =>
{
var question = questionMap[item.QuestionExternalId];
var choiceId = index % 3 == 0
? item.PresentedChoiceIds.First(id => id != question.CorrectChoiceId)
: question.CorrectChoiceId;
return new ExamSimulationResponse
{
QuestionExternalId = item.QuestionExternalId,
ChoiceId = choiceId,
ElapsedSeconds = 75 + index % 45,
Flagged = index % 11 == 0
};
}).ToList();
var responseMap = responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
return new ExamSimulationStateResponse
{
Id = "preview",
State = ExamSimulationStates.Completed,
CurrentBlock = BlockSizes.Length - 1,
BreakSecondsRemaining = 3300,
Responses = responses,
Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap),
BlueprintTargets = targets,
BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()),
ReviewQuestions = items.Select(item => QuestionResponse.FromModel(
questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList()
};
}
public async Task<ExamSimulation?> FindActiveAsync(string userId) => public async Task<ExamSimulation?> FindActiveAsync(string userId) =>
await _simulations.Find(item => item.UserId == userId && await _simulations.Find(item => item.UserId == userId &&
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive)) (item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
@@ -261,6 +309,28 @@ public class ExamSimulationStore
private Task ReplaceAsync(ExamSimulation simulation) => private Task ReplaceAsync(ExamSimulation simulation) =>
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation); _simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
private static int BlockIndexForPosition(int position)
{
var consumed = 0;
for (var block = 0; block < BlockSizes.Length; block++)
{
if (position < consumed + BlockSizes[block]) return block;
consumed += BlockSizes[block];
}
return BlockSizes.Length - 1;
}
private static int PositionWithinBlock(int position)
{
var consumed = 0;
foreach (var size in BlockSizes)
{
if (position < consumed + size) return position - consumed;
consumed += size;
}
return 0;
}
private static ExamSimulationQuestionResponse SafeQuestion(Question question, List<string> presentedIds) => new() private static ExamSimulationQuestionResponse SafeQuestion(Question question, List<string> presentedIds) => new()
{ {
ExternalId = question.ExternalId, ExternalId = question.ExternalId,
@@ -41,6 +41,10 @@ func create_simulation() -> void:
_send("create", HTTPClient.METHOD_POST, "") _send("create", HTTPClient.METHOD_POST, "")
func load_results_preview() -> void:
_send("preview", HTTPClient.METHOD_GET, "/preview-results")
func start_next_block() -> void: func start_next_block() -> void:
if simulation.is_empty(): if simulation.is_empty():
return return
+27
View File
@@ -13,6 +13,7 @@ var bank_error_label: Label
var retry_button: Button var retry_button: Button
var simulation_button: Button var simulation_button: Button
var simulation_status_label: Label var simulation_status_label: Label
var simulation_preview_link: LinkButton
var pending_simulation_launch := false var pending_simulation_launch := false
var current_stage := "exam" var current_stage := "exam"
var pending_question_bank_launch := false var pending_question_bank_launch := false
@@ -32,6 +33,8 @@ func _ready() -> void:
ExamSimulationService.state_changed.connect(_on_simulation_state_changed) ExamSimulationService.state_changed.connect(_on_simulation_state_changed)
if not ExamSimulationService.request_failed.is_connected(_on_simulation_failed): if not ExamSimulationService.request_failed.is_connected(_on_simulation_failed):
ExamSimulationService.request_failed.connect(_on_simulation_failed) ExamSimulationService.request_failed.connect(_on_simulation_failed)
if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility):
AuthState.auth_changed.connect(_refresh_simulation_preview_visibility)
_build_exam_view() _build_exam_view()
_build_mode_view() _build_mode_view()
@@ -44,6 +47,7 @@ func on_show(payload: Variant = null) -> void:
elif str(payload) == "exam": elif str(payload) == "exam":
_show_stage("exam") _show_stage("exam")
_refresh_bank_state() _refresh_bank_state()
_refresh_simulation_preview_visibility()
ExamSimulationService.load_readiness() ExamSimulationService.load_readiness()
@@ -160,6 +164,16 @@ func _build_mode_view() -> void:
Style.set_label(simulation_status_label, 15, Style.MUTED) Style.set_label(simulation_status_label, 15, Style.MUTED)
mode_view.add_child(simulation_status_label) mode_view.add_child(simulation_status_label)
simulation_preview_link = LinkButton.new()
simulation_preview_link.text = "Temporary: preview completed exam results"
simulation_preview_link.tooltip_text = "SUPER only; generates a nonpersistent simulated result"
simulation_preview_link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
simulation_preview_link.add_theme_color_override("font_color", Style.CYAN)
simulation_preview_link.add_theme_color_override("font_hover_color", Style.MAGENTA)
simulation_preview_link.pressed.connect(_preview_simulation_results)
mode_view.add_child(simulation_preview_link)
_refresh_simulation_preview_visibility()
bank_status_label = Label.new() bank_status_label = Label.new()
bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(bank_status_label, 16, Style.CYAN) Style.set_label(bank_status_label, 16, Style.CYAN)
@@ -243,6 +257,17 @@ func _select_simulation() -> void:
ExamSimulationService.create_simulation() ExamSimulationService.create_simulation()
func _preview_simulation_results() -> void:
pending_simulation_launch = true
simulation_preview_link.disabled = true
ExamSimulationService.load_results_preview()
func _refresh_simulation_preview_visibility() -> void:
if simulation_preview_link != null:
simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER"
func _on_simulation_state_changed() -> void: func _on_simulation_state_changed() -> void:
_refresh_simulation_state() _refresh_simulation_state()
if not pending_simulation_launch or ExamSimulationService.simulation.is_empty(): if not pending_simulation_launch or ExamSimulationService.simulation.is_empty():
@@ -269,6 +294,8 @@ func _launch_simulation_block() -> void:
func _on_simulation_failed(message: String) -> void: func _on_simulation_failed(message: String) -> void:
pending_simulation_launch = false pending_simulation_launch = false
if simulation_preview_link != null:
simulation_preview_link.disabled = false
if simulation_status_label != null: if simulation_status_label != null:
simulation_status_label.text = message simulation_status_label.text = message
_refresh_simulation_state() _refresh_simulation_state()