From 102933db446d137160b08eb7fc82632f6d1ff5d3 Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 18 Jul 2026 15:14:48 -0500 Subject: [PATCH] Count up question bank answer timer --- scripts/autoload/block_session_service.gd | 49 ++++++++++++++++++++++- scripts/ui/question_player.gd | 44 +++++++++++++------- tests/choice_pool_test.gd | 23 +++++++++++ tests/post_answer_review_test.gd | 29 ++++++++++++++ 4 files changed, 128 insertions(+), 17 deletions(-) diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 9adcc87..7ebd77b 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -20,6 +20,10 @@ var core_current_target_seconds := 90.0 var core_current_had_wrong_attempt := false var current_question_started_at := 0.0 var current_question_pause_started_at := 0.0 +var core_question_total_elapsed_seconds := 0.0 +var core_question_total_active_since := 0.0 +var core_question_display_target_seconds := 90.0 +var core_question_total_frozen := false var core_status_message := "" var simulation_block_index := 0 var simulation_total_blocks := 12 @@ -62,6 +66,10 @@ func start_core_run() -> bool: core_target_seconds = 90.0 core_current_target_seconds = core_target_seconds core_current_had_wrong_attempt = false + core_question_total_elapsed_seconds = 0.0 + core_question_total_active_since = 0.0 + core_question_display_target_seconds = core_target_seconds + core_question_total_frozen = false core_status_message = "" if not advance_core_question(): block_started = false @@ -161,6 +169,7 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> if bool(answer.get("is_correct", false)): var mastered := not core_current_had_wrong_attempt answers[current_index] = answer + _freeze_core_question_total_timer() _apply_core_answer_rewards(answer, mastered) session_changed.emit() return { @@ -311,6 +320,10 @@ func append_core_question(question: Dictionary) -> bool: current_question_started_at = Time.get_ticks_msec() / 1000.0 current_question_pause_started_at = 0.0 core_current_target_seconds = core_target_seconds + core_question_display_target_seconds = core_current_target_seconds + core_question_total_elapsed_seconds = 0.0 + core_question_total_active_since = current_question_started_at + core_question_total_frozen = false core_current_had_wrong_attempt = false session_changed.emit() return true @@ -339,6 +352,21 @@ func get_core_timer_ratio() -> float: return clamp(ratio, 0.0, 1.0) +func get_core_question_total_elapsed_seconds() -> float: + if session_kind != "core": + return 0.0 + var elapsed := core_question_total_elapsed_seconds + if core_question_total_active_since > 0.0: + elapsed += Time.get_ticks_msec() / 1000.0 - core_question_total_active_since + return maxf(0.0, elapsed) + + +func get_core_question_elapsed_ratio() -> float: + if session_kind != "core" or core_question_display_target_seconds <= 0.0: + return 0.0 + return clampf(get_core_question_total_elapsed_seconds() / core_question_display_target_seconds, 0.0, 1.0) + + func get_session_timer_ratio() -> float: if session_kind == "core": return get_core_timer_ratio() @@ -349,14 +377,31 @@ func get_session_timer_ratio() -> float: func pause_current_question_timer() -> void: if session_kind == "core" and current_question_pause_started_at <= 0.0: - current_question_pause_started_at = Time.get_ticks_msec() / 1000.0 + var now := Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = now + _pause_core_question_total_timer(now) func resume_current_question_timer() -> void: if session_kind == "core" and current_question_pause_started_at > 0.0: - var paused_duration := Time.get_ticks_msec() / 1000.0 - current_question_pause_started_at + var now := Time.get_ticks_msec() / 1000.0 + var paused_duration := now - current_question_pause_started_at current_question_started_at += paused_duration current_question_pause_started_at = 0.0 + if not core_question_total_frozen: + core_question_total_active_since = now + + +func _pause_core_question_total_timer(now: float) -> void: + if core_question_total_active_since <= 0.0: + return + core_question_total_elapsed_seconds += maxf(0.0, now - core_question_total_active_since) + core_question_total_active_since = 0.0 + + +func _freeze_core_question_total_timer() -> void: + _pause_core_question_total_timer(Time.get_ticks_msec() / 1000.0) + core_question_total_frozen = true func _draw_core_question() -> Dictionary: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index d77bb9f..5ccefc9 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -986,8 +986,7 @@ func _load_question() -> void: timer_footer.visible = show_timer if show_timer: if BlockSessionService.session_kind == "core": - var core_seconds := int(ceil(BlockSessionService.core_current_target_seconds - BlockSessionService.get_current_elapsed_seconds())) - _set_timer_display(maxi(0, core_seconds)) + _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) else: _set_timer_display(int(ceil(BlockSessionService.seconds_remaining))) _update_timer_bar() @@ -1297,9 +1296,7 @@ func _show_answer_review() -> void: _show_references(BlockSessionService.get_current_question().get("references", [])) _ensure_answer_review_followups() _render_answer_review_sections() - timer_bar.visible = false - timer_label.visible = false - timer_footer.visible = false + _set_answer_review_timer_visibility() next_button.text = "Next" next_button.disabled = false if skip_button != null: @@ -1309,6 +1306,15 @@ func _show_answer_review() -> void: question_scroll.call_deferred("ensure_control_visible", explanation_label) +func _set_answer_review_timer_visibility() -> void: + var keep_core_timer := BlockSessionService.session_kind == "core" + timer_bar.visible = keep_core_timer + timer_label.visible = keep_core_timer + timer_footer.visible = keep_core_timer + if keep_core_timer: + _update_timer_bar() + + func _show_explanation() -> void: var question := BlockSessionService.get_current_question() for button in choice_buttons: @@ -1619,19 +1625,27 @@ func _exit_run() -> void: func _update_timer_bar() -> void: if timer_bar == null: return - var ratio := BlockSessionService.get_session_timer_ratio() + var is_core_timer := BlockSessionService.session_kind == "core" + var ratio := BlockSessionService.get_core_question_elapsed_ratio() if is_core_timer else BlockSessionService.get_session_timer_ratio() timer_bar.value = ratio * 100.0 - if BlockSessionService.session_kind == "core" and timer_label.visible: - var remaining := int(ceil(BlockSessionService.core_current_target_seconds - BlockSessionService.get_current_elapsed_seconds())) - _set_timer_display(maxi(0, remaining)) + if is_core_timer and timer_label.visible: + _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) var color_state := "blue" var fill_color := Brand.ROYAL_BLUE - if ratio < 0.25: - color_state = "red" - fill_color = Style.RED - elif ratio <= 0.5: - color_state = "amber" - fill_color = Style.AMBER + if is_core_timer: + if ratio >= 1.0: + color_state = "red" + fill_color = Style.RED + elif ratio >= 0.75: + color_state = "amber" + fill_color = Style.AMBER + else: + if ratio < 0.25: + color_state = "red" + fill_color = Style.RED + elif ratio <= 0.5: + color_state = "amber" + fill_color = Style.AMBER if color_state != timer_color_state: timer_color_state = color_state timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color)) diff --git a/tests/choice_pool_test.gd b/tests/choice_pool_test.gd index 9311338..0e56732 100644 --- a/tests/choice_pool_test.gd +++ b/tests/choice_pool_test.gd @@ -28,6 +28,29 @@ func _initialize() -> void: service.session_initial_seconds = 900.0 service.seconds_remaining = 450.0 _assert(is_equal_approx(service.get_session_timer_ratio(), 0.5), "timed block progress should use the full session duration") + service.session_kind = "core" + service.core_question_display_target_seconds = 100.0 + service.core_question_total_elapsed_seconds = 25.0 + service.core_question_total_active_since = 0.0 + _assert(is_equal_approx(service.get_core_question_total_elapsed_seconds(), 25.0), "core question timer should expose cumulative elapsed time") + _assert(is_equal_approx(service.get_core_question_elapsed_ratio(), 0.25), "core question progress should fill toward its original target") + service._start_core_retry_window() + _assert(is_equal_approx(service.get_core_question_total_elapsed_seconds(), 25.0), "wrong-answer retry windows should not reset cumulative question time") + service.core_question_total_active_since = 100.0 + service.core_question_total_elapsed_seconds = 4.0 + service._pause_core_question_total_timer(106.0) + _assert(is_equal_approx(service.get_core_question_total_elapsed_seconds(), 10.0), "pausing should preserve accumulated question time") + service.core_question_total_elapsed_seconds = 0.0 + service.core_question_total_active_since = 0.001 + service._freeze_core_question_total_timer() + var frozen_elapsed: float = service.get_core_question_total_elapsed_seconds() + OS.delay_msec(10) + _assert(is_equal_approx(service.get_core_question_total_elapsed_seconds(), frozen_elapsed), "freezing should stop cumulative question time") + var resume_now := Time.get_ticks_msec() / 1000.0 + service.current_question_started_at = maxf(0.001, resume_now - 5.0) + service.current_question_pause_started_at = maxf(0.001, resume_now - 0.001) + service.resume_current_question_timer() + _assert(service.core_question_total_active_since == 0.0, "a completed core question timer should not restart") service.free() quit(1 if failures > 0 else 0) diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 1ec87f9..582e46c 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -21,10 +21,39 @@ func _initialize() -> void: _test_feedback_form_validation() _test_revisitable_navigation_prefers_unanswered_questions() _test_submit_styles_keep_stable_geometry() + _test_core_timer_counts_up_and_remains_visible() await _test_player_runtime_construction() quit(1 if failures > 0 else 0) +func _test_core_timer_counts_up_and_remains_visible() -> void: + var player = load("res://scripts/ui/question_player.gd").new() + var block_service = root.get_node("BlockSessionService") + player.timer_bar = ProgressBar.new() + player.timer_label = Label.new() + player.timer_footer = HBoxContainer.new() + player.timer_color_state = "" + block_service.session_kind = "core" + block_service.core_question_display_target_seconds = 100.0 + block_service.core_question_total_elapsed_seconds = 75.0 + block_service.core_question_total_active_since = 0.0 + player._set_answer_review_timer_visibility() + _assert(player.timer_footer.visible and player.timer_bar.visible and player.timer_label.visible, "core timer should remain visible during correct-answer review") + _assert(is_equal_approx(player.timer_bar.value, 75.0), "core timer bar should fill with elapsed progress") + _assert(player.timer_label.text == "01:15", "core timer should display cumulative elapsed time") + _assert(player.timer_color_state == "amber", "core timer should warn when approaching its target") + block_service.core_question_total_elapsed_seconds = 110.0 + player._update_timer_bar() + _assert(is_equal_approx(player.timer_bar.value, 100.0) and player.timer_color_state == "red", "core timer should remain full and red after exceeding its target") + block_service.session_kind = "block" + player._set_answer_review_timer_visibility() + _assert(not player.timer_footer.visible, "non-core answer review should retain its existing hidden timer") + player.timer_bar.free() + player.timer_label.free() + player.timer_footer.free() + player.free() + + func _test_reference_normalization() -> void: var service = load("res://scripts/autoload/question_bank_service.gd").new() var references: Array = service._normalize_references([{