diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd index a7681e2..167f9ed 100644 --- a/scripts/autoload/credits_service.gd +++ b/scripts/autoload/credits_service.gd @@ -9,6 +9,7 @@ signal award_received(amount: int, reason: String) signal ledger_changed const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" var credits: int = 0 @@ -86,7 +87,8 @@ func apply_award_response(parsed: Dictionary) -> void: continue var award: Dictionary = award_variant var amount := int(award.get("amount", 0)) - if amount > 0: + var reason_code := str(award.get("reasonCode", award.get("reason_code", ""))) + if amount > 0 and (FeatureFlags.progression_enabled() or not FeatureFlags.is_progression_reward(reason_code)): award_received.emit(amount, str(award.get("description", "Learning reward"))) diff --git a/scripts/config/feature_flags.gd b/scripts/config/feature_flags.gd new file mode 100644 index 0000000..e56eeb4 --- /dev/null +++ b/scripts/config/feature_flags.gd @@ -0,0 +1,17 @@ +extends RefCounted + + +static func progression_enabled() -> bool: + return str(ProjectSettings.get_setting("step3/deployment_environment", "development")).to_lower() == "uat" + + +static func is_progression_reward(reason_code: String) -> bool: + return reason_code in [ + "mastery_practicing", + "mastery_strong", + "mastery_mastered", + "session_goal_complete", + "daily_mission_complete", + "weekly_goal_complete", + "rank_promotion", + ] diff --git a/scripts/config/feature_flags.gd.uid b/scripts/config/feature_flags.gd.uid new file mode 100644 index 0000000..569bb59 --- /dev/null +++ b/scripts/config/feature_flags.gd.uid @@ -0,0 +1 @@ +uid://bve4tb6xllsnf diff --git a/scripts/ui/block_setup.gd b/scripts/ui/block_setup.gd index cb50181..014998a 100644 --- a/scripts/ui/block_setup.gd +++ b/scripts/ui/block_setup.gd @@ -1,6 +1,7 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") var main: Control var selected_system := "All Systems" @@ -14,6 +15,7 @@ var targeted_setup := false var goal_option: OptionButton var target_label: Label var pending_launch := false +var goal_row: HBoxContainer func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) @@ -32,7 +34,8 @@ func _ready() -> void: Style.set_label(system_label, 22, Style.CYAN) add_child(system_label) - var goal_row := HBoxContainer.new() + goal_row = HBoxContainer.new() + goal_row.visible = FeatureFlags.progression_enabled() var goal_label := Label.new() goal_label.text = "Finish line" goal_row.add_child(goal_label) @@ -88,7 +91,7 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: - if typeof(payload) == TYPE_DICTIONARY: + if typeof(payload) == TYPE_DICTIONARY and FeatureFlags.progression_enabled(): targeted_setup = bool(payload.get("mastery_target", false)) selected_system = str(payload.get("step3_system", "All Systems")) requested_count = int(payload.get("question_count", 10)) @@ -102,7 +105,7 @@ func on_show(payload: Variant = null) -> void: else: goal_option.set_meta("mastery_target", false) targeted_setup = false - selected_system = str(payload) if payload != null else "All Systems" + selected_system = str(payload.get("step3_system", "All Systems")) if typeof(payload) == TYPE_DICTIONARY else (str(payload) if payload != null else "All Systems") _refresh_setup() @@ -130,6 +133,10 @@ func _start_block() -> void: if not QuestionBankService.has_questions(): status_label.text = "Questions have not loaded from the API yet." return + if not FeatureFlags.progression_enabled(): + BlockSessionService.start_block(selected_system, int(size_spin.value), mode_option.get_item_text(mode_option.selected)) + main.show_screen("player") + return pending_launch = true start_button.disabled = true status_label.text = "Preparing your study goal..." diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 1cea3e1..8b686ea 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -6,6 +6,7 @@ const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd") const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd") const DashboardSegmentBar := preload("res://scripts/ui/dashboard_segment_bar.gd") const DashboardInfoBadge := preload("res://scripts/ui/dashboard_info_badge.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") var main: Control var scroll: ScrollContainer @@ -93,9 +94,11 @@ func _build() -> void: stat_card_grid.add_theme_constant_override("h_separation", 12) stat_card_grid.add_theme_constant_override("v_separation", 12) content.add_child(stat_card_grid) - mission_content = VBoxContainer.new() - mission_content.add_theme_constant_override("separation", 10) - content.add_child(_card(mission_content, 16)) + mission_content = null + if FeatureFlags.progression_enabled(): + mission_content = VBoxContainer.new() + mission_content.add_theme_constant_override("separation", 10) + content.add_child(_card(mission_content, 16)) qbank_sparkline = DashboardSparkline.new() qbank_sparkline.custom_minimum_size = Vector2(0, 34) @@ -147,7 +150,7 @@ func _build() -> void: performance.add_child(trend_chart) performance.add_child(HSeparator.new()) var system_header := HBoxContainer.new() - system_header.add_child(_section_title("System mastery path")) + system_header.add_child(_section_title("System mastery path" if FeatureFlags.progression_enabled() else "Performance by system")) var header_space := Control.new() header_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL system_header.add_child(header_space) @@ -166,9 +169,11 @@ func _build() -> void: right_column.add_theme_constant_override("separation", 12) right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL analytics_grid.add_child(right_column) - rank_content = VBoxContainer.new() - rank_content.add_theme_constant_override("separation", 9) - right_column.add_child(_card(rank_content, 16)) + rank_content = null + if FeatureFlags.progression_enabled(): + rank_content = VBoxContainer.new() + rank_content.add_theme_constant_override("separation", 9) + right_column.add_child(_card(rank_content, 16)) focus_content = VBoxContainer.new() focus_content.add_theme_constant_override("separation", 10) right_column.add_child(_card(focus_content, 16)) @@ -196,9 +201,11 @@ func _build() -> void: weekly_summary.add_theme_constant_override("separation", 10) weekly_row.add_child(weekly_summary) weekly.add_child(HSeparator.new()) - weekly_goal_content = VBoxContainer.new() - weekly_goal_content.add_theme_constant_override("separation", 8) - weekly.add_child(weekly_goal_content) + weekly_goal_content = null + if FeatureFlags.progression_enabled(): + weekly_goal_content = VBoxContainer.new() + weekly_goal_content.add_theme_constant_override("separation", 8) + weekly.add_child(weekly_goal_content) lower_grid.add_child(_card(weekly, 16)) recent_list = VBoxContainer.new() recent_list.add_theme_constant_override("separation", 6) @@ -225,16 +232,22 @@ func _render() -> void: _render_readiness_card(data) _render_accuracy_card(data) trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode()) - _render_systems(data.get("systems", [])) - _render_focus(data.get("systems", [])) + if FeatureFlags.progression_enabled(): + _render_systems(data.get("systems", [])) + _render_focus(data.get("systems", [])) + else: + _render_systems_legacy(data.get("systems", [])) + _render_focus_legacy(data.get("systems", [])) _render_review(data) - _render_rank(data.get("progression", {})) + if FeatureFlags.progression_enabled(): + _render_rank(data.get("progression", {})) var weekly: Array = data.get("weeklyActivity", []) activity_chart.set_days(weekly, UIThemeService.is_dark_mode()) _render_weekly(data, weekly) _render_recent(data.get("recentActivity", [])) - _render_missions() - _render_weekly_goal() + if FeatureFlags.progression_enabled(): + _render_missions() + _render_weekly_goal() func _render_rank(value: Variant) -> void: @@ -845,6 +858,10 @@ func _start_review_queue(step3_system: String = "") -> void: pending_review_launch = true _show_loading_message("Preparing your review queue...") set_meta("pending_review_system", step3_system) + if not FeatureFlags.progression_enabled(): + review_queue_service.start_pass(step3_system) + _render_review(DashboardService.data) + return if not StudySessionService.session_started.is_connected(_on_review_session_started): StudySessionService.session_started.connect(_on_review_session_started) if not StudySessionService.session_failed.is_connected(_on_review_session_failed): diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 308a691..2b4a16e 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") var screens := {} var _account_profile_synced := false @@ -39,7 +40,8 @@ func _ready() -> void: Style.apply_app_theme(self) _build_ui() CreditsService.award_received.connect(_show_coin_award) - StudySessionService.session_completed.connect(_on_study_session_completed) + if FeatureFlags.progression_enabled(): + StudySessionService.session_completed.connect(_on_study_session_completed) theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_update_responsive_layout) _update_responsive_layout() @@ -47,6 +49,8 @@ func _ready() -> void: func _on_study_session_completed(summary: Dictionary) -> void: + if not FeatureFlags.progression_enabled(): + return WeeklyGoalService.refresh() call_deferred("show_screen", "session_summary", summary) @@ -293,11 +297,12 @@ func _build_ui() -> void: screens_container.add_child(review) screens["review"] = review - var session_summary := preload("res://scripts/ui/session_summary.gd").new() - session_summary.name = "SessionSummary" - session_summary.main = self - screens_container.add_child(session_summary) - screens["session_summary"] = session_summary + if FeatureFlags.progression_enabled(): + var session_summary := preload("res://scripts/ui/session_summary.gd").new() + session_summary.name = "SessionSummary" + session_summary.main = self + screens_container.add_child(session_summary) + screens["session_summary"] = session_summary var review_queue_results := preload("res://scripts/ui/review_queue_results.gd").new() review_queue_results.name = "ReviewQueueResults" diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 209436b..94e92c8 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -1,6 +1,7 @@ extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") var main: Control var scroll: ScrollContainer @@ -96,7 +97,9 @@ func _build() -> void: right.add_theme_constant_override("separation", 14) columns.add_child(right) right.add_child(_outcome_card()) - right.add_child(_progression_card()) + progression_content = null + if FeatureFlags.progression_enabled(): + right.add_child(_progression_card()) right.add_child(_connected_accounts_card()) right.add_child(_account_actions_card()) _refresh_back_to_session_visibility() @@ -288,7 +291,8 @@ func _render() -> void: _set_metric("cases", str(int(data.get("ccsCasesCompleted", 0)))) _set_metric("streak", "%d days" % int(data.get("currentStreak", 0))) _render_systems(data.get("systems", [])) - _render_progression(data.get("progression", {})) + if FeatureFlags.progression_enabled(): + _render_progression(data.get("progression", {})) _render_outcome() diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 1cccd23..e58c633 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -6,6 +6,7 @@ const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dial const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd") const SlotMachinePanel := preload("res://scripts/ui/rz_slot_machine_screen.gd") const CreditsBadge := preload("res://scripts/ui/credits_badge.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg") const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg") const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg") @@ -1224,7 +1225,7 @@ func _process(delta: float) -> void: return if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core": _update_timer_bar() - var duration_goal := str(StudySessionService.progress.get("goalType", "")) == "duration" + var duration_goal := FeatureFlags.progression_enabled() and str(StudySessionService.progress.get("goalType", "")) == "duration" if visible and BlockSessionService.block_started and (BlockSessionService.mode == "Timed" or duration_goal) and (report_overlay == null or not report_overlay.visible): BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) @@ -2081,10 +2082,10 @@ func _open_reference_url(url: String) -> void: func _advance_or_finish() -> void: - if not StudySessionService.summary.is_empty(): + if FeatureFlags.progression_enabled() and not StudySessionService.summary.is_empty(): StudySessionService.emit_deferred_summary() return - if str(StudySessionService.progress.get("goalType", "")) == "duration" and BlockSessionService.seconds_remaining <= 0: + if FeatureFlags.progression_enabled() and str(StudySessionService.progress.get("goalType", "")) == "duration" and BlockSessionService.seconds_remaining <= 0: StudySessionService.check_completion() return if BlockSessionService.move_next(): @@ -2111,7 +2112,7 @@ func _finish() -> void: func _exit_run() -> void: - if StudySessionService.is_active() and BlockSessionService.session_kind != "simulation": + if FeatureFlags.progression_enabled() and StudySessionService.is_active() and BlockSessionService.session_kind != "simulation": StudySessionService.finish_early() if BlockSessionService.session_kind == "bookmark": BlockSessionService.finish_block() @@ -2165,7 +2166,7 @@ func _set_timer_display(total_seconds: int) -> void: func _is_session_timer_visible() -> bool: if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation": return true - if str(StudySessionService.progress.get("goalType", "")) == "duration": + if FeatureFlags.progression_enabled() and str(StudySessionService.progress.get("goalType", "")) == "duration": return true return BlockSessionService.mode == "Timed" diff --git a/scripts/ui/review_queue_results.gd b/scripts/ui/review_queue_results.gd index 5533651..afe830a 100644 --- a/scripts/ui/review_queue_results.gd +++ b/scripts/ui/review_queue_results.gd @@ -1,6 +1,7 @@ extends VBoxContainer const Brand := preload("res://scripts/ui/medmind_style.gd") +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") var main: Control var title_label: Label @@ -84,6 +85,9 @@ func _start_another() -> void: another_button.disabled = true status_label.text = "Preparing another review pass..." status_label.visible = true + if not FeatureFlags.progression_enabled(): + ReviewQueueService.start_pass("", 20) + return if not StudySessionService.session_started.is_connected(_on_review_session_started): StudySessionService.session_started.connect(_on_review_session_started) if not StudySessionService.session_failed.is_connected(_on_review_session_failed): diff --git a/tests/dashboard_metric_ui_test.gd b/tests/dashboard_metric_ui_test.gd index 4cab4f0..39616e5 100644 --- a/tests/dashboard_metric_ui_test.gd +++ b/tests/dashboard_metric_ui_test.gd @@ -8,6 +8,8 @@ func _initialize() -> void: func _run() -> void: + var prior_environment: String = ProjectSettings.get_setting("step3/deployment_environment", "development") + ProjectSettings.set_setting("step3/deployment_environment", "uat") var dashboard_service = root.get_node("DashboardService") var mission_service = root.get_node("DailyMissionService") var weekly_goal_service = root.get_node("WeeklyGoalService") @@ -51,9 +53,25 @@ func _run() -> void: _assert(dashboard.trend_chart.points.size() == 30, "chart should retain all 30 calendar points") dashboard.queue_free() + await process_frame + + ProjectSettings.set_setting("step3/deployment_environment", "prd") + var production_dashboard = load("res://scripts/ui/dashboard_screen.gd").new() + production_dashboard.size = Vector2(1280, 900) + root.add_child(production_dashboard) + await process_frame + production_dashboard._render() + await process_frame + _assert(production_dashboard.mission_content == null, "production should omit daily mission UI") + _assert(production_dashboard.weekly_goal_content == null, "production should omit weekly goal UI") + _assert(production_dashboard.rank_content == null, "production should omit clinical progression UI") + _assert(production_dashboard.mastery_rows.is_empty(), "production should render legacy system performance instead of mastery actions") + _assert(production_dashboard.system_list.get_child_count() == 14, "production should retain the system performance breakdown") + production_dashboard.queue_free() dashboard_service.data = prior_data mission_service.state = prior_missions weekly_goal_service.state = prior_weekly_goal + ProjectSettings.set_setting("step3/deployment_environment", prior_environment) await process_frame quit(1 if failures > 0 else 0) diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 40e6b79..7a01629 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -206,7 +206,7 @@ func _test_resolved_followup_stays_expanded_and_toggles() -> void: _assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option") _assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option") var verdict := content.find_child("FollowupVerdict", true, false) as Label - _assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct") + _assert(verdict != null and verdict.text.is_empty(), "an incorrect follow-up should rely on its red answer treatment instead of redundant verdict text") if toggle != null: toggle.pressed.emit() _assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up") @@ -297,10 +297,8 @@ func _test_review_followup_is_optional_and_single_attempt() -> void: player.review_followups_section.add_child(player.review_followups_content) player._render_review_followups() _assert(not player.next_button.disabled, "optional review follow-ups must not lock Next") - player._answer_review_followup(0, "B") - _assert(bool(player.review_followup_states[0].get("answered", false)), "a review follow-up should record its first response") - player._answer_review_followup(0, "A") - _assert(player.review_followup_states[0].get("selected_choice_id", "") == "B", "review follow-ups should accept only one attempt") + _assert(not player.review_followups_section.visible, "generic post-answer review follow-ups should remain retired") + _assert(player.review_followup_states.is_empty(), "retired generic follow-ups should not create answer state") player.review_followups_section.free() player.next_button.free() player.free() diff --git a/tests/progression_feature_flag_test.gd b/tests/progression_feature_flag_test.gd new file mode 100644 index 0000000..1f88fea --- /dev/null +++ b/tests/progression_feature_flag_test.gd @@ -0,0 +1,104 @@ +extends SceneTree + +const FeatureFlags := preload("res://scripts/config/feature_flags.gd") + +var failures := 0 + + +class ScreenHost extends Control: + var shown_screen := "" + + func show_screen(screen_name: String, _payload: Variant = null) -> void: + shown_screen = screen_name + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var prior_environment: String = ProjectSettings.get_setting("step3/deployment_environment", "development") + ProjectSettings.set_setting("step3/deployment_environment", "uat") + _assert(FeatureFlags.progression_enabled(), "UAT should enable progression") + + for environment in ["prd", "production", "development", "unknown"]: + ProjectSettings.set_setting("step3/deployment_environment", environment) + _assert(not FeatureFlags.progression_enabled(), "%s should not enable progression" % environment) + + ProjectSettings.set_setting("step3/deployment_environment", "prd") + var received: Array = [] + var credits = root.get_node("CreditsService") + var prior_credits: int = credits.credits + var prior_loaded: bool = credits.loaded + var capture := func(amount: int, reason: String): received.append([amount, reason]) + credits.award_received.connect(capture) + credits.apply_award_response({"credits": {"credits": 123}, "creditAwards": [{ + "amount": 10, "reasonCode": "rank_promotion", "description": "Advanced rank" + }]}) + _assert(received.is_empty(), "production should suppress progression award notifications") + _assert(credits.credits == 123, "production should still apply balances containing progression awards") + credits.apply_award_response({"creditAwards": [{ + "amount": 2, "reasonCode": "qbank_difficult_correct", "description": "Difficult question correct" + }]}) + _assert(received.size() == 1, "production should retain ordinary learning reward notifications") + credits.award_received.disconnect(capture) + credits.credits = prior_credits + credits.loaded = prior_loaded + + var production_setup = load("res://scripts/ui/block_setup.gd").new() + var screen_host := ScreenHost.new() + root.add_child(screen_host) + production_setup.main = screen_host + root.add_child(production_setup) + await process_frame + _assert(not production_setup.goal_row.visible, "production block setup should hide progression finish lines") + var question_bank = root.get_node("QuestionBankService") + var prior_questions: Array = question_bank.questions.duplicate(true) + question_bank.questions = [_sample_question()] + production_setup.selected_system = "All Systems" + production_setup.size_spin.value = 5 + production_setup._start_block() + _assert(screen_host.shown_screen == "player", "production should launch a classic block directly") + _assert(root.get_node("StudySessionService").active_session_id.is_empty(), "production classic blocks should not create progression sessions") + root.get_node("BlockSessionService").finish_block() + question_bank.questions = prior_questions + production_setup.queue_free() + screen_host.queue_free() + + var production_profile = load("res://scripts/ui/progress_profile.gd").new() + root.add_child(production_profile) + await process_frame + _assert(production_profile.progression_content == null, "production profile should omit clinical progression") + production_profile.queue_free() + + ProjectSettings.set_setting("step3/deployment_environment", "uat") + var uat_setup = load("res://scripts/ui/block_setup.gd").new() + root.add_child(uat_setup) + await process_frame + _assert(uat_setup.goal_row.visible, "UAT block setup should retain progression finish lines") + uat_setup.queue_free() + + ProjectSettings.set_setting("step3/deployment_environment", prior_environment) + await process_frame + quit(1 if failures > 0 else 0) + + +func _sample_question() -> Dictionary: + var choices: Array = [] + for index in 5: + choices.append({"id": String.chr(65 + index), "text": "Choice %d" % index}) + return { + "external_id": "progression-flag-test", + "system": "Cardiovascular", + "stem": "Test stem", + "lead_in": "Choose an answer.", + "choices": choices, + "correct_choice_id": "A", + } + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/progression_feature_flag_test.gd.uid b/tests/progression_feature_flag_test.gd.uid new file mode 100644 index 0000000..24b14f6 --- /dev/null +++ b/tests/progression_feature_flag_test.gd.uid @@ -0,0 +1 @@ +uid://cl00e10sgfqei