Add functional simulation question review

This commit is contained in:
hz
2026-07-14 17:49:23 -05:00
parent 3e39a4ffce
commit 705f079997
13 changed files with 551 additions and 32 deletions
+12
View File
@@ -32,6 +32,18 @@ public sealed class ChoicePoolTests
Assert.Equal("https://example.org/guideline", response.References[0].Url); Assert.Equal("https://example.org/guideline", response.References[0].Url);
} }
[Fact]
public void ExamReviewContainsOnlyChoicesPresentedDuringTheExam()
{
var question = BuildQuestion(9);
var presented = new[] { "F", "A", "C", "H", "D" };
var response = QuestionResponse.FromModelWithChoices(question, presented);
Assert.Equal(presented, response.Choices.Select(choice => choice.Id));
Assert.Equal(presented.Order(), response.ChoiceExplanations.Keys.Order());
Assert.DoesNotContain("B", response.Choices.Select(choice => choice.Id));
}
[Fact] [Fact]
public void PublishedQuestionRequiresCompletePerChoiceTeachingContent() public void PublishedQuestionRequiresCompletePerChoiceTeachingContent()
{ {
@@ -40,6 +40,10 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation)); return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation));
} }
[HttpGet("completed")]
public async Task<IActionResult> Completed([FromQuery] int limit = 20, [FromQuery] DateTime? before = null) =>
Ok(await simulations.GetCompletedAsync(UserId(), limit, before));
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<IActionResult> Get(string id) public async Task<IActionResult> Get(string id)
{ {
+18
View File
@@ -119,6 +119,24 @@ public class ExamSimulationReadinessResponse
public bool HasActiveSimulation { get; set; } public bool HasActiveSimulation { get; set; }
} }
public class CompletedExamSimulationSummary
{
public string Id { get; set; } = "";
public DateTime CompletedUtc { get; set; }
public int PredictedScore { get; set; }
public int RangeLow { get; set; }
public int RangeHigh { get; set; }
public int Correct { get; set; }
public int Total { get; set; }
public int SeenCount { get; set; }
}
public class CompletedExamSimulationPage
{
public List<CompletedExamSimulationSummary> Items { get; set; } = [];
public DateTime? NextBefore { get; set; }
}
public class Step3Outcome public class Step3Outcome
{ {
[BsonId] [BsonId]
@@ -62,6 +62,24 @@ public class QuestionResponse
}; };
} }
public static QuestionResponse FromModelWithChoices(
Question question,
IReadOnlyCollection<string> presentedChoiceIds,
int questionElo = 1000)
{
var response = FromModel(question, questionElo: questionElo, includeChoicePool: true);
var ids = presentedChoiceIds.ToHashSet(StringComparer.Ordinal);
response.Choices = presentedChoiceIds
.Select(id => question.Choices.FirstOrDefault(choice => choice.Id == id))
.Where(choice => choice is not null)
.Cast<QuestionChoice>()
.ToList();
response.ChoiceExplanations = response.ChoiceExplanations.Where(entry => ids.Contains(entry.Key)).ToDictionary();
response.WrongAnswerFollowups = response.WrongAnswerFollowups.Where(entry => ids.Contains(entry.Key)).ToDictionary();
response.ChoiceSelectionPercentages = response.ChoiceSelectionPercentages.Where(entry => ids.Contains(entry.Key)).ToDictionary();
return response;
}
private static List<QuestionChoice> SelectLegacyChoices(Question question) private static List<QuestionChoice> SelectLegacyChoices(Question question)
{ {
if (question.Choices.Count <= 5) if (question.Choices.Count <= 5)
@@ -33,6 +33,8 @@ public class ExamSimulationStore
_metrics = metrics; _metrics = metrics;
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>( _simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State))); Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc)));
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>( _simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc))); Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>( _outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
@@ -136,8 +138,8 @@ public class ExamSimulationStore
Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap), Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap),
BlueprintTargets = targets, BlueprintTargets = targets,
BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()), BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()),
ReviewQuestions = items.Select(item => QuestionResponse.FromModel( ReviewQuestions = items.Select(item => QuestionResponse.FromModelWithChoices(
questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList() questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList()
}; };
} }
@@ -154,6 +156,33 @@ public class ExamSimulationStore
return simulation; return simulation;
} }
public async Task<CompletedExamSimulationPage> GetCompletedAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.UserId, userId) &
Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed);
if (before is not null)
filter &= Builders<ExamSimulation>.Filter.Lt(item => item.CompletedUtc, before.Value);
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
return new CompletedExamSimulationPage
{
Items = simulations.Select(item => new CompletedExamSimulationSummary
{
Id = item.Id ?? "",
CompletedUtc = item.CompletedUtc ?? item.UpdatedUtc,
PredictedScore = item.Score?.PredictedScore ?? 0,
RangeLow = item.Score?.RangeLow ?? 0,
RangeHigh = item.Score?.RangeHigh ?? 0,
Correct = item.Score?.Correct ?? 0,
Total = item.Score?.Total ?? item.Items.Count,
SeenCount = item.Score?.SeenCount ?? 0
}).ToList(),
NextBefore = hasMore && simulations.Count > 0 ? simulations[^1].CompletedUtc : null
};
}
public async Task<ExamSimulation> StartNextBlockAsync(ExamSimulation simulation) public async Task<ExamSimulation> StartNextBlockAsync(ExamSimulation simulation)
{ {
if (simulation.State != ExamSimulationStates.BetweenBlocks) if (simulation.State != ExamSimulationStates.BetweenBlocks)
@@ -244,7 +273,7 @@ public class ExamSimulationStore
{ {
response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position) response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position)
.Where(item => questionMap.ContainsKey(item.QuestionExternalId)) .Where(item => questionMap.ContainsKey(item.QuestionExternalId))
.Select(item => QuestionResponse.FromModel(questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList(); .Select(item => QuestionResponse.FromModelWithChoices(questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList();
} }
return response; return response;
} }
@@ -7,6 +7,7 @@ signal readiness_changed
signal state_changed signal state_changed
signal request_failed(message: String) signal request_failed(message: String)
signal outcome_changed signal outcome_changed
signal completed_history_changed
var api_url := DEFAULT_API_URL var api_url := DEFAULT_API_URL
var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes" var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes"
@@ -14,6 +15,8 @@ var request: HTTPRequest
var readiness := {} var readiness := {}
var simulation := {} var simulation := {}
var outcome := {} var outcome := {}
var completed_history: Array = []
var completed_history_next_before := ""
var is_loading := false var is_loading := false
var pending_action := "" var pending_action := ""
var request_queue: Array = [] var request_queue: Array = []
@@ -37,6 +40,17 @@ func load_active() -> void:
_send("active", HTTPClient.METHOD_GET, "/active") _send("active", HTTPClient.METHOD_GET, "/active")
func load_completed_history(before: String = "") -> void:
var suffix := "/completed?limit=20"
if not before.is_empty():
suffix += "&before=%s" % before.uri_encode()
_send("history_append" if not before.is_empty() else "history", HTTPClient.METHOD_GET, suffix)
func load_simulation(simulation_id: String) -> void:
_send("completed_get", HTTPClient.METHOD_GET, "/%s" % simulation_id.uri_encode())
func create_simulation() -> void: func create_simulation() -> void:
_send("create", HTTPClient.METHOD_POST, "") _send("create", HTTPClient.METHOD_POST, "")
@@ -156,6 +170,12 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
readiness = parsed readiness = parsed
readiness_changed.emit() readiness_changed.emit()
return return
if action == "history" or action == "history_append":
var items: Array = parsed.get("items", [])
completed_history = completed_history + items if action == "history_append" else items
completed_history_next_before = str(parsed.get("nextBefore", ""))
completed_history_changed.emit()
return
if action.begins_with("outcome_"): if action.begins_with("outcome_"):
outcome = parsed outcome = parsed
outcome_changed.emit() outcome_changed.emit()
+6
View File
@@ -167,6 +167,12 @@ func _build_ui() -> void:
screens_container.add_child(simulation_results) screens_container.add_child(simulation_results)
screens["simulation_results"] = simulation_results screens["simulation_results"] = simulation_results
var simulation_review := preload("res://scripts/ui/simulation_review.gd").new()
simulation_review.name = "SimulationReview"
simulation_review.main = self
screens_container.add_child(simulation_review)
screens["simulation_review"] = simulation_review
var profile := preload("res://scripts/ui/progress_profile.gd").new() var profile := preload("res://scripts/ui/progress_profile.gd").new()
profile.name = "ProgressProfile" profile.name = "ProgressProfile"
profile.main = self profile.main = self
+9 -29
View File
@@ -6,12 +6,12 @@ var main: Control
var score_label: Label var score_label: Label
var summary_label: Label var summary_label: Label
var category_list: VBoxContainer var category_list: VBoxContainer
var review_list: VBoxContainer
var outcome_score: SpinBox var outcome_score: SpinBox
var outcome_date: LineEdit var outcome_date: LineEdit
var outcome_consent: CheckBox var outcome_consent: CheckBox
var outcome_status: Label var outcome_status: Label
var outcome_delete_button: Button var outcome_delete_button: Button
var current_simulation := {}
func _ready() -> void: func _ready() -> void:
@@ -42,13 +42,13 @@ func _ready() -> void:
content.add_child(category_heading) content.add_child(category_heading)
category_list = VBoxContainer.new() category_list = VBoxContainer.new()
content.add_child(category_list) content.add_child(category_list)
var review_heading := Label.new() var review_button := Button.new()
review_heading.text = "Answer review" review_button.text = "Review Questions"
Style.set_label(review_heading, 22, Style.TEXT) review_button.custom_minimum_size = Vector2(240, 50)
content.add_child(review_heading) review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
review_list = VBoxContainer.new() Style.style_primary_button(review_button)
review_list.add_theme_constant_override("separation", 8) review_button.pressed.connect(func(): main.show_screen("simulation_review", current_simulation))
content.add_child(review_list) content.add_child(review_button)
var outcome_heading := Label.new() var outcome_heading := Label.new()
outcome_heading.text = "Help calibrate future predictions" outcome_heading.text = "Help calibrate future predictions"
@@ -102,6 +102,7 @@ func _ready() -> void:
func on_show(payload: Variant = null) -> void: func on_show(payload: Variant = null) -> void:
var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation
current_simulation = simulation
var score: Dictionary = simulation.get("score", {}) var score: Dictionary = simulation.get("score", {})
var predicted := int(score.get("predictedScore", 0)) var predicted := int(score.get("predictedScore", 0))
var low := int(score.get("rangeLow", 0)) var low := int(score.get("rangeLow", 0))
@@ -117,30 +118,9 @@ func on_show(payload: Variant = null) -> void:
label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))] label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))]
Style.set_label(label, 16, Style.TEXT) Style.set_label(label, 16, Style.TEXT)
category_list.add_child(label) category_list.add_child(label)
_build_review(simulation)
ExamSimulationService.load_outcome() ExamSimulationService.load_outcome()
func _build_review(simulation: Dictionary) -> void:
_clear(review_list)
var responses := {}
for response in simulation.get("responses", []):
responses[str(response.get("questionExternalId", ""))] = response
for raw_question in simulation.get("reviewQuestions", []):
if typeof(raw_question) != TYPE_DICTIONARY:
continue
var question: Dictionary = raw_question
var id := str(question.get("externalId", ""))
var selected := str(responses.get(id, {}).get("choiceId", ""))
var correct := str(question.get("correctChoiceId", ""))
var item := RichTextLabel.new()
item.bbcode_enabled = true
item.fit_content = true
item.custom_minimum_size = Vector2(0, 84)
item.text = "[b]%s[/b] %s\nSelected: %s | Correct: %s\n%s" % ["CORRECT" if selected == correct else "MISS", str(question.get("leadIn", "")), selected if selected != "" else "Unanswered", correct, str(question.get("explanation", ""))]
review_list.add_child(item)
func _clear(container: Control) -> void: func _clear(container: Control) -> void:
for child in container.get_children(): for child in container.get_children():
child.queue_free() child.queue_free()
+342
View File
@@ -0,0 +1,342 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19]
var main: Control
var simulation := {}
var questions: Array = []
var responses := {}
var filtered_indices: Array[int] = []
var review_position := 0
var active_filter := "all"
var progress_label: Label
var content: VBoxContainer
var previous_button: Button
var next_button: Button
var empty_label: Label
var media_requests: Array[HTTPRequest] = []
var image_overlay: Control
var image_texture: TextureRect
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 10)
var toolbar := HBoxContainer.new()
toolbar.add_theme_constant_override("separation", 8)
add_child(toolbar)
var back := Button.new()
back.text = "< Results"
Style.style_chip(back, Style.CYAN)
back.pressed.connect(func(): main.show_screen("simulation_results", simulation))
toolbar.add_child(back)
progress_label = Label.new()
progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(progress_label, 17, Style.TEXT)
toolbar.add_child(progress_label)
var filters := GridContainer.new()
filters.columns = 4
filters.add_theme_constant_override("h_separation", 8)
filters.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(filters)
var group := ButtonGroup.new()
for definition in [["All", "all"], ["Incorrect", "incorrect"], ["Unanswered", "unanswered"], ["Flagged", "flagged"]]:
var button := Button.new()
button.text = definition[0]
button.toggle_mode = true
button.button_group = group
button.button_pressed = definition[1] == "all"
Style.style_chip(button, Style.CYAN)
button.pressed.connect(_set_filter.bind(definition[1]))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
filters.add_child(button)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(scroll)
content = VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 10)
scroll.add_child(content)
empty_label = Label.new()
empty_label.visible = false
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(empty_label, 19, Style.MUTED)
content.add_child(empty_label)
var navigation := HBoxContainer.new()
navigation.add_theme_constant_override("separation", 10)
add_child(navigation)
previous_button = Button.new()
previous_button.text = "< Previous"
previous_button.custom_minimum_size = Vector2(180, 48)
Style.style_chip(previous_button, Style.CYAN)
previous_button.pressed.connect(_move.bind(-1))
navigation.add_child(previous_button)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
navigation.add_child(spacer)
next_button = Button.new()
next_button.text = "Next >"
next_button.custom_minimum_size = Vector2(180, 48)
Style.style_primary_button(next_button)
next_button.pressed.connect(_move.bind(1))
navigation.add_child(next_button)
_build_image_overlay()
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_DICTIONARY:
simulation = payload
questions = simulation.get("reviewQuestions", [])
responses.clear()
for response in simulation.get("responses", []):
responses[str(response.get("questionExternalId", ""))] = response
active_filter = "all"
review_position = 0
_apply_filter()
func on_hide() -> void:
_clear_media_requests()
image_overlay.visible = false
func _unhandled_key_input(event: InputEvent) -> void:
if not visible or not event.pressed or event.echo:
return
if event.keycode == KEY_LEFT:
_move(-1)
get_viewport().set_input_as_handled()
elif event.keycode == KEY_RIGHT:
_move(1)
get_viewport().set_input_as_handled()
elif event.keycode == KEY_ESCAPE and image_overlay.visible:
image_overlay.visible = false
get_viewport().set_input_as_handled()
func _set_filter(filter_name: String) -> void:
active_filter = filter_name
review_position = 0
_apply_filter()
func _apply_filter() -> void:
filtered_indices.clear()
for index in range(questions.size()):
var question: Dictionary = questions[index]
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
var selected := str(response.get("choiceId", ""))
var correct := str(question.get("correctChoiceId", ""))
if _matches_filter(active_filter, selected, correct, bool(response.get("flagged", false))):
filtered_indices.append(index)
_render_question()
func _move(direction: int) -> void:
var target := review_position + direction
if target < 0 or target >= filtered_indices.size():
return
review_position = target
_render_question()
func _render_question() -> void:
_clear_content()
_clear_media_requests()
if filtered_indices.is_empty():
empty_label = Label.new()
empty_label.text = "No questions match this filter."
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(empty_label, 19, Style.MUTED)
content.add_child(empty_label)
progress_label.text = "0 questions"
previous_button.disabled = true
next_button.disabled = true
return
var question_index := filtered_indices[review_position]
var question: Dictionary = questions[question_index]
var response: Dictionary = responses.get(str(question.get("externalId", "")), {})
var selected := str(response.get("choiceId", ""))
var correct := str(question.get("correctChoiceId", ""))
progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"]
previous_button.disabled = review_position == 0
next_button.disabled = review_position + 1 >= filtered_indices.size()
_add_text(str(question.get("stem", "")), 18, Style.TEXT)
_add_media(question.get("media", []))
_add_labs(question.get("labs", []))
_add_text(str(question.get("leadIn", "")), 21, Style.TEXT)
var explanations: Dictionary = question.get("choiceExplanations", {})
for raw_choice in question.get("choices", []):
var choice: Dictionary = raw_choice
var choice_id := str(choice.get("id", ""))
var panel := PanelContainer.new()
var border := Style.LIME if choice_id == correct else Style.RED if choice_id == selected else Style.BORDER
var background := Color("1f7a3a") if choice_id == correct else Color("8f1d2c") if choice_id == selected else Style.SURFACE
panel.add_theme_stylebox_override("panel", Style.panel_box(background, border))
content.add_child(panel)
var choice_content := VBoxContainer.new()
panel.add_child(choice_content)
var suffix := " [Correct answer]" if choice_id == correct else " [Your answer]" if choice_id == selected else ""
var answer := Label.new()
answer.text = "%s. %s%s" % [choice_id, str(choice.get("text", "")), suffix]
answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(answer, 17, Style.TEXT)
choice_content.add_child(answer)
var explanation := str(explanations.get(choice_id, ""))
if not explanation.is_empty():
var detail := Label.new()
detail.text = explanation
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(detail, 15, Style.TEXT)
choice_content.add_child(detail)
_add_heading("Explanation")
_add_text(str(question.get("explanation", "")), 17, Style.TEXT)
_add_references(question.get("references", []))
func _add_heading(value: String) -> void:
var label := Label.new()
label.text = value
Style.set_label(label, 20, Style.CYAN)
content.add_child(label)
func _add_text(value: String, size_value: int, color: Color) -> void:
var label := Label.new()
label.text = value
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(label, size_value, color)
content.add_child(label)
func _add_labs(labs: Array) -> void:
if labs.is_empty():
return
_add_heading("Laboratory studies")
for raw_lab in labs:
var lab: Dictionary = raw_lab
_add_text("%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 15, Style.TEXT)
func _add_references(references: Array) -> void:
if references.is_empty():
return
_add_heading("References")
for raw_reference in references:
var reference: Dictionary = raw_reference
var url := str(reference.get("url", ""))
if not url.begins_with("https://"):
continue
var link := LinkButton.new()
link.text = str(reference.get("title", url))
link.tooltip_text = url
link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
link.add_theme_color_override("font_color", Style.CYAN)
link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url))
content.add_child(link)
func _add_media(media: Array) -> void:
for raw_url in media:
var url := _resolve_media_url(str(raw_url))
if url.is_empty():
continue
var preview := TextureRect.new()
preview.custom_minimum_size = Vector2(0, 260)
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
content.add_child(preview)
var request := HTTPRequest.new()
request.request_completed.connect(func(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, code, body, preview))
media_requests.append(request)
add_child(request)
request.request(url)
func _on_media_loaded(result: int, code: int, body: PackedByteArray, preview: TextureRect) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300 or not is_instance_valid(preview):
return
var image := Image.new()
var error := image.load_png_from_buffer(body)
if error != OK: error = image.load_jpg_from_buffer(body)
if error != OK: error = image.load_webp_from_buffer(body)
if error != OK: return
preview.texture = ImageTexture.create_from_image(image)
preview.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
image_texture.texture = preview.texture
image_overlay.visible = true
)
func _build_image_overlay() -> void:
image_overlay = Control.new()
image_overlay.visible = false
image_overlay.top_level = true
image_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
image_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(image_overlay)
var background := ColorRect.new()
background.color = Color(Style.BACKGROUND, 0.97)
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
image_overlay.add_child(background)
image_texture = TextureRect.new()
image_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
image_texture.offset_left = 24
image_texture.offset_top = 72
image_texture.offset_right = -24
image_texture.offset_bottom = -24
image_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
image_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
image_overlay.add_child(image_texture)
var close := Button.new()
close.text = "X"
close.position = Vector2(16, 16)
close.custom_minimum_size = Vector2(44, 44)
Style.style_chip(close, Style.CYAN)
close.pressed.connect(func(): image_overlay.visible = false)
image_overlay.add_child(close)
func _resolve_media_url(value: String) -> String:
if value.begins_with("http://") or value.begins_with("https://"):
return value
var path := value if value.begins_with("/") else "/%s" % value
var base := QuestionBankService.question_api_base_url
var scheme_end := base.find("://")
var path_start := base.find("/", scheme_end + 3)
return "%s%s" % [base if path_start < 0 else base.substr(0, path_start), path]
func _clear_content() -> void:
for child in content.get_children():
child.queue_free()
func _clear_media_requests() -> void:
for request in media_requests:
if is_instance_valid(request): request.queue_free()
media_requests.clear()
func _block_for_index(index: int) -> int:
var consumed := 0
for block in range(BLOCK_SIZES.size()):
if index < consumed + BLOCK_SIZES[block]: return block
consumed += BLOCK_SIZES[block]
return BLOCK_SIZES.size() - 1
static func _matches_filter(filter_name: String, selected: String, correct: String, flagged: bool) -> bool:
match filter_name:
"incorrect": return selected != "" and selected != correct
"unanswered": return selected == ""
"flagged": return flagged
_: return true
+1
View File
@@ -0,0 +1 @@
uid://cyt8fuoixldi2
+49
View File
@@ -14,6 +14,8 @@ 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 simulation_preview_link: LinkButton
var completed_history_list: VBoxContainer
var completed_history_more: Button
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
@@ -35,6 +37,8 @@ func _ready() -> void:
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): if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility):
AuthState.auth_changed.connect(_refresh_simulation_preview_visibility) AuthState.auth_changed.connect(_refresh_simulation_preview_visibility)
if not ExamSimulationService.completed_history_changed.is_connected(_refresh_completed_history):
ExamSimulationService.completed_history_changed.connect(_refresh_completed_history)
_build_exam_view() _build_exam_view()
_build_mode_view() _build_mode_view()
@@ -49,6 +53,7 @@ func on_show(payload: Variant = null) -> void:
_refresh_bank_state() _refresh_bank_state()
_refresh_simulation_preview_visibility() _refresh_simulation_preview_visibility()
ExamSimulationService.load_readiness() ExamSimulationService.load_readiness()
ExamSimulationService.load_completed_history()
func on_hide() -> void: func on_hide() -> void:
@@ -174,6 +179,26 @@ func _build_mode_view() -> void:
mode_view.add_child(simulation_preview_link) mode_view.add_child(simulation_preview_link)
_refresh_simulation_preview_visibility() _refresh_simulation_preview_visibility()
var history_heading := Label.new()
history_heading.text = "Completed simulations"
Style.set_label(history_heading, 20, Style.TEXT)
mode_view.add_child(history_heading)
completed_history_list = VBoxContainer.new()
completed_history_list.add_theme_constant_override("separation", 6)
completed_history_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var history_scroll := ScrollContainer.new()
history_scroll.custom_minimum_size = Vector2(0, 150)
history_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
history_scroll.add_child(completed_history_list)
mode_view.add_child(history_scroll)
completed_history_more = Button.new()
completed_history_more.text = "Load more"
completed_history_more.visible = false
completed_history_more.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(completed_history_more, Style.CYAN)
completed_history_more.pressed.connect(func(): ExamSimulationService.load_completed_history(ExamSimulationService.completed_history_next_before))
mode_view.add_child(completed_history_more)
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)
@@ -269,6 +294,30 @@ func _refresh_simulation_preview_visibility() -> void:
simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER" simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER"
func _refresh_completed_history() -> void:
if completed_history_list == null:
return
for child in completed_history_list.get_children():
child.queue_free()
for raw_summary in ExamSimulationService.completed_history:
var summary: Dictionary = raw_summary
var button := Button.new()
button.text = "%s | Predicted %d (%d-%d) | %d/%d correct" % [
str(summary.get("completedUtc", "")).left(10), int(summary.get("predictedScore", 0)),
int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0)),
int(summary.get("correct", 0)), int(summary.get("total", 0))]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
Style.style_chip(button, Style.CYAN)
button.pressed.connect(_open_completed_simulation.bind(str(summary.get("id", ""))))
completed_history_list.add_child(button)
completed_history_more.visible = not ExamSimulationService.completed_history_next_before.is_empty()
func _open_completed_simulation(simulation_id: String) -> void:
pending_simulation_launch = true
ExamSimulationService.load_simulation(simulation_id)
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():
+39
View File
@@ -0,0 +1,39 @@
extends SceneTree
var failures := 0
func _initialize() -> void:
var review_script = load("res://scripts/ui/simulation_review.gd")
_assert(review_script._matches_filter("all", "A", "B", false), "all should include every response")
_assert(review_script._matches_filter("incorrect", "A", "B", false), "incorrect should include wrong answers")
_assert(not review_script._matches_filter("incorrect", "", "B", false), "incorrect should exclude unanswered questions")
_assert(review_script._matches_filter("unanswered", "", "B", false), "unanswered should include blank responses")
_assert(review_script._matches_filter("flagged", "B", "B", true), "flagged should include flagged correct responses")
_assert(not review_script._matches_filter("flagged", "A", "B", false), "flagged should exclude unflagged responses")
var review = review_script.new()
get_root().add_child(review)
await process_frame
review.on_show({
"reviewQuestions": [
{"externalId": "q1", "stem": "Stem one", "leadIn": "Lead one", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "A", "choiceExplanations": {"A": "Correct", "B": "Wrong"}, "explanation": "Overall", "labs": [], "media": [], "references": []},
{"externalId": "q2", "stem": "Stem two", "leadIn": "Lead two", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "B", "choiceExplanations": {}, "explanation": "Overall", "labs": [], "media": [], "references": []}
],
"responses": [{"questionExternalId": "q1", "choiceId": "B", "flagged": true}]
})
_assert(review.filtered_indices.size() == 2, "all filter should build a complete review set")
review._set_filter("incorrect")
_assert(review.filtered_indices == [0], "incorrect filter should retain only answered misses")
review._set_filter("unanswered")
_assert(review.filtered_indices == [1], "unanswered filter should retain blank responses")
await process_frame
review.queue_free()
await process_frame
quit(1 if failures > 0 else 0)
func _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)
+1
View File
@@ -0,0 +1 @@
uid://dn2mvv1kmhup3