# test_phase3c_bugfix.gd # Headless regression suite for the Phase 3c bugfix pass (five bugs): # # Bug 1 (queue rows never parented): QueuePanel._make_row now calls # _list.add_child(panel), so rows are actually in the rendered tree. # Regression: assert the rendered tree — _list.get_child_count() matches # queue size + the always-present empty label, each _rows entry is a direct # child of _list, and each row has non-zero geometry after the panel pops up # (layout frame) instead of asserting only _rows.size(). # # Bug 2 (rule rows never parented): same fix in RulePanel._make_row, same # rendered-tree assertions for the filtered rule list. # # Bug 3 ("Edit Queue…" gating): in BOTH the Director action popup # (ACT_EDIT_QUEUE) and the stickman right-click context popup # (RIG_CTX_EDIT_QUEUE) the item is disabled when rig.get_queue().is_empty() # and enabled otherwise, refreshed on about_to_popup by # _refresh_action_popup_items() / _refresh_rig_context_items(). # # Bug 4 ("Edit Rules…" gating): the same two popups disable # ACT_EDIT_RULES / RIG_CTX_EDIT_RULES when no rule has # trigger.source == rig.get_instance_id(), and enable them otherwise. # # Bug 5 (font/size config): sandbox_theme.json's extended fonts block is # parsed — ui_font_bold/ui_font_italic, per-widget sizes # (queue/rule/action_editor/rule_editor/panel_row/panel_title), the # panel_title_bold / rule_label_bold / badge_bold flags, and the # "action_popup" object form — with defaults preserved when the keys are # absent. action_popup_emoji_size is consumed (previously dead) and applied # by _apply_popup_theme; the four Phase 3c widgets expose apply_font(...) # and tolerate null fonts; stage_director_visuals.set_style() honours the # bold flags. # # Run with: # & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_bugfix.gd --path . # # Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL. extends SceneTree const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") var _checks := 0 var _failures := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: var watchdog := create_timer(120.0) watchdog.timeout.connect(func() -> void: print("FAIL: watchdog timeout - test run aborted before quit()") quit(2)) print("") print("========================================================") print(" PHASE 3c BUGFIX REGRESSION TEST (headless)") print("========================================================") await _test_queue_rows_in_rendered_tree() await _test_rule_rows_in_rendered_tree() await _test_edit_queue_gating() await _test_edit_rules_gating() await _test_theme_keys_parsed_and_defaults() await _test_apply_font_null_fonts_and_bold_title() await _test_popup_emoji_size_applied() await _test_visuals_style_bold_flags() print("--------------------------------------------------------") if _failures == 0: print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks) quit(0) else: print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) quit(1) # --------------------------------------------------------------------------- # Bug 1: QueuePanel rows are parented into _list and render. # --------------------------------------------------------------------------- func _test_queue_rows_in_rendered_tree() -> void: print("") print("--- QueuePanel rows live in _list with real geometry ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "stickman rig spawns for queue tree test") if rig == null: await _free_stage(stage) return rig.queue_action({ "type": "walk_to", "target": Vector2(100, -100) }) rig.queue_action({ "type": "speak", "text": "Hello", "duration": 2.0 }) rig.queue_action({ "type": "wait", "duration": 3.0 }) rig.queue_action({ "type": "ragdoll" }) stage._open_queue_panel(rig) var qp: QueuePanel = stage._queue_panel _check(qp._rows.size() == 4, "queue panel tracks 4 rows (got %d)" % qp._rows.size()) # The empty label is an always-present child of _list, so the rendered tree # must hold label + one row per queue entry. This is the assertion the # pre-fix suite missed (it only counted _rows.size()). _check(qp._list.get_child_count() == 5, "_list holds the empty label + 4 row children (got %d)" % qp._list.get_child_count()) var all_parented := true var all_children := true for row: PanelContainer in qp._rows: if row.get_parent() != qp._list: all_parented = false if not qp._list.get_children().has(row): all_children = false _check(all_parented, "every queue row's parent is _list") _check(all_children, "every queue row is a child of _list") # After the popup lays out, every row must have non-zero geometry. await process_frame await process_frame var geometry_ok := true for i: int in qp._rows.size(): var row: PanelContainer = qp._rows[i] if row.size.x <= 0.0 or row.size.y <= 0.0: geometry_ok = false print(" row %d size = %s" % [i, str(row.size)]) _check(geometry_ok, "all 4 queue rows have non-zero geometry after layout") # Clear the queue: refresh must empty the rendered tree down to the label. rig.clear_queue() qp.refresh() await process_frame _check(qp._rows.is_empty(), "queue refresh clears _rows after clear") _check(qp._list.get_child_count() == 1, "queue _list drops to just the empty label after clear (got %d)" % qp._list.get_child_count()) _check(qp._empty_label.visible, "queue empty label visible after clear") # Re-add one action and refresh: rendered tree follows. rig.queue_action({ "type": "wait", "duration": 1.0 }) qp.refresh() await process_frame _check(qp._list.get_child_count() == 2, "queue _list regrows one row after re-add (got %d)" % qp._list.get_child_count()) _check(qp._rows[0].get_parent() == qp._list, "regrown queue row is parented into _list") await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 2: RulePanel rows are parented into _list and render. # --------------------------------------------------------------------------- func _test_rule_rows_in_rendered_tree() -> void: print("") print("--- RulePanel rows live in _list with real geometry ---") var stage := _new_stage() var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0)) _check(rigA != null and rigB != null, "two stickmen spawn for rule tree test") if rigA == null or rigB == null: await _free_stage(stage) return var rule0 := { "id": 0, "trigger": { "type": "arrived_at_waypoint", "source": rigA.get_instance_id(), "target": -1, "params": { "waypoint_pos": Vector2(100, -100) } }, "actions": [{ "type": "speak", "target": rigB.get_instance_id(), "params": { "text": "Hi", "duration": 1.0 } }], } var rule1 := { "id": 1, "trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} }, "actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 2.0 } }], } var rules: Array[Dictionary] = [rule0, rule1] stage._event_rules = rules stage._open_rules_panel_for_rig(rigA) var rp: RulePanel = stage._rule_panel _check(rp._rows.size() == 2, "rule panel tracks 2 filtered rows (got %d)" % rp._rows.size()) _check(rp._list.get_child_count() == 3, "rule _list holds the empty label + 2 row children (got %d)" % rp._list.get_child_count()) var all_parented := true for row: PanelContainer in rp._rows: if row.get_parent() != rp._list: all_parented = false _check(all_parented, "every rule row's parent is _list") await process_frame await process_frame var geometry_ok := true for i: int in rp._rows.size(): var row: PanelContainer = rp._rows[i] if row.size.x <= 0.0 or row.size.y <= 0.0: geometry_ok = false print(" rule row %d size = %s" % [i, str(row.size)]) _check(geometry_ok, "all rule rows have non-zero geometry after layout") # A rig with no rules shows an empty rendered tree (label only). stage._open_rules_panel_for_rig(rigB) await process_frame _check(stage._rule_panel._rows.is_empty(), "no-rule filter clears _rows") _check(stage._rule_panel._list.get_child_count() == 1, "no-rule filter leaves just the empty label (got %d)" % stage._rule_panel._list.get_child_count()) _check(stage._rule_panel._empty_label.visible, "rule empty label visible with no rules") await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 3: "Edit Queue…" gating (queue empty -> disabled, else enabled) in both # popups, refreshed on about_to_popup. # --------------------------------------------------------------------------- func _test_edit_queue_gating() -> void: print("") print("--- Edit Queue… gating in action popup + rig context popup ---") var stage := _new_stage() var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "stickman rig spawns for queue gating test") if rig == null: await _free_stage(stage) return # Empty queue -> ACT_EDIT_QUEUE disabled in the Director action popup. stage._context_rig = rig stage._action_popup.popup(Rect2i(10, 10, 0, 0)) var act_idx: int = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE) _check(act_idx >= 0, "action popup contains ACT_EDIT_QUEUE") _check(stage._action_popup.is_item_disabled(act_idx), "action popup Edit Queue… disabled with an empty queue") # Empty queue -> RIG_CTX_EDIT_QUEUE disabled in the rig right-click menu. stage._open_rig_context(Vector2(0, 0), rig) var ctx_idx: int = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_QUEUE) _check(ctx_idx >= 0, "rig context popup contains RIG_CTX_EDIT_QUEUE") _check(stage._rig_context_popup.is_item_disabled(ctx_idx), "rig context Edit Queue… disabled with an empty queue") stage._rig_context_popup.hide() # Non-empty queue -> both items enabled on the next popup. rig.queue_action({ "type": "walk_to", "target": Vector2(300, -300) }) stage._action_popup.popup(Rect2i(10, 10, 0, 0)) act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE) _check(not stage._action_popup.is_item_disabled(act_idx), "action popup Edit Queue… enabled with a queued action") stage._action_popup.hide() stage._open_rig_context(Vector2(0, 0), rig) ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_QUEUE) _check(not stage._rig_context_popup.is_item_disabled(ctx_idx), "rig context Edit Queue… enabled with a queued action") stage._rig_context_popup.hide() # Back to empty: gating follows the live queue (disabled again). rig.clear_queue() stage._action_popup.popup(Rect2i(10, 10, 0, 0)) act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_QUEUE) _check(stage._action_popup.is_item_disabled(act_idx), "action popup Edit Queue… re-disabled after the queue clears") stage._action_popup.hide() await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 4: "Edit Rules…" gating (no rule with source == rig -> disabled, else # enabled) in both popups. # --------------------------------------------------------------------------- func _test_edit_rules_gating() -> void: print("") print("--- Edit Rules… gating in action popup + rig context popup ---") var stage := _new_stage() var rigA: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rigB: StickmanRig = stage._spawner.spawn("stickman", Vector2(400, 0)) _check(rigA != null and rigB != null, "two stickmen spawn for rules gating test") if rigA == null or rigB == null: await _free_stage(stage) return # No rules at all -> both popups disable Edit Rules… for rigA. stage._context_rig = rigA stage._action_popup.popup(Rect2i(10, 10, 0, 0)) var act_idx: int = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES) _check(stage._action_popup.is_item_disabled(act_idx), "action popup Edit Rules… disabled when rigA has no rules") stage._action_popup.hide() stage._open_rig_context(Vector2(0, 0), rigA) var ctx_idx: int = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES) _check(stage._rig_context_popup.is_item_disabled(ctx_idx), "rig context Edit Rules… disabled when rigA has no rules") stage._rig_context_popup.hide() # A rule owned by ANOTHER rig (rigB) must not enable rigA's entry. var other_rule := { "id": 0, "trigger": { "type": "arrived_at_waypoint", "source": rigB.get_instance_id(), "target": -1, "params": { "waypoint_pos": Vector2(100, -100) } }, "actions": [], } var other_rules: Array[Dictionary] = [other_rule] stage._event_rules = other_rules stage._action_popup.popup(Rect2i(10, 10, 0, 0)) act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES) _check(stage._action_popup.is_item_disabled(act_idx), "action popup Edit Rules… stays disabled for rigA when only rigB owns rules") stage._action_popup.hide() stage._open_rig_context(Vector2(0, 0), rigA) ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES) _check(stage._rig_context_popup.is_item_disabled(ctx_idx), "rig context Edit Rules… stays disabled for rigA when only rigB owns rules") stage._rig_context_popup.hide() # A rule sourced by rigA enables both entries. var my_rule := { "id": 1, "trigger": { "type": "action_finished", "source": rigA.get_instance_id(), "target": -1, "params": {} }, "actions": [{ "type": "wait", "target": rigB.get_instance_id(), "params": { "duration": 1.0 } }], } var my_rules: Array[Dictionary] = [my_rule] stage._event_rules = my_rules stage._action_popup.popup(Rect2i(10, 10, 0, 0)) act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES) _check(not stage._action_popup.is_item_disabled(act_idx), "action popup Edit Rules… enabled when rigA owns a rule") stage._action_popup.hide() stage._open_rig_context(Vector2(0, 0), rigA) ctx_idx = stage._rig_context_popup.get_item_index(stage.RIG_CTX_EDIT_RULES) _check(not stage._rig_context_popup.is_item_disabled(ctx_idx), "rig context Edit Rules… enabled when rigA owns a rule") stage._rig_context_popup.hide() # Removing the rigA rule re-disables the entries (live _event_rules read). var empty_rules: Array[Dictionary] = [] stage._event_rules = empty_rules stage._action_popup.popup(Rect2i(10, 10, 0, 0)) act_idx = stage._action_popup.get_item_index(stage.ACT_EDIT_RULES) _check(stage._action_popup.is_item_disabled(act_idx), "action popup Edit Rules… re-disabled after the rigA rule is removed") stage._action_popup.hide() await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 5a: sandbox_theme.json extended fonts block parses; defaults preserved. # --------------------------------------------------------------------------- func _test_theme_keys_parsed_and_defaults() -> void: print("") print("--- Theme parse: new keys honoured, absent keys keep defaults ---") var stage := _new_stage() var full_path := "user://bugfix_theme_full.json" _write_theme(full_path, { "fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "action_popup_font_size": 30, "action_popup_emoji_size": 19, "queue_panel_font_size": 21, "rule_panel_font_size": 22, "action_editor_font_size": 23, "rule_editor_font_size": 24, "panel_row_font_size": 14, "panel_title_font_size": 17, "panel_title_bold": false, "rule_label_bold": true, "badge_bold": false, "action_popup": { "size": 33, "bold": true, "italic": false }, }, "grid": { "snap_size": 35.0 }, }) stage._load_theme(full_path) _check(stage._queue_panel_font_size == 21, "queue_panel_font_size parsed (got %d)" % stage._queue_panel_font_size) _check(stage._rule_panel_font_size == 22, "rule_panel_font_size parsed (got %d)" % stage._rule_panel_font_size) _check(stage._action_editor_font_size == 23, "action_editor_font_size parsed (got %d)" % stage._action_editor_font_size) _check(stage._rule_editor_font_size == 24, "rule_editor_font_size parsed (got %d)" % stage._rule_editor_font_size) _check(stage._panel_row_font_size == 14, "panel_row_font_size parsed (got %d)" % stage._panel_row_font_size) _check(stage._panel_title_font_size == 17, "panel_title_font_size parsed (got %d)" % stage._panel_title_font_size) _check(stage._panel_title_bold == false, "panel_title_bold parsed (got %s)" % str(stage._panel_title_bold)) _check(stage._rule_label_bold == true, "rule_label_bold parsed (got %s)" % str(stage._rule_label_bold)) _check(stage._badge_bold == false, "badge_bold parsed (got %s)" % str(stage._badge_bold)) # action_popup object form overrides the flat action_popup_font_size. _check(stage._action_popup_font_size == 33, "action_popup {size} overrides the flat key (got %d)" % stage._action_popup_font_size) _check(stage._action_popup_bold == true, "action_popup {bold} parsed (got %s)" % str(stage._action_popup_bold)) _check(stage._action_popup_italic == false, "action_popup {italic} parsed (got %s)" % str(stage._action_popup_italic)) _check(stage._action_popup_emoji_size == 19, "action_popup_emoji_size consumed from the theme (got %d)" % stage._action_popup_emoji_size) _check(int(stage._font_sizes.get("queue_panel", -1)) == 21, "_font_sizes[queue_panel] matches") _check(int(stage._font_sizes.get("action_editor", -1)) == 23, "_font_sizes[action_editor] matches") _check(int(stage._font_sizes.get("panel_row", -1)) == 14, "_font_sizes[panel_row] matches") _check(int(stage._font_sizes.get("panel_title", -1)) == 17, "_font_sizes[panel_title] matches") _check(bool(stage._font_sizes.get("panel_title_bold", true)) == false, "_font_sizes[panel_title_bold] matches") _check(stage._font_sizes.get("bold_font", null) == null, "_font_sizes[bold_font] null with no ui_font configured") # Theme missing the new keys -> shipped defaults remain. var bare_path := "user://bugfix_theme_bare.json" _write_theme(bare_path, { "grid": { "snap_size": 10.0 } }) stage._load_theme(bare_path) _check(stage._action_popup_font_size == 24, "absent action_popup_font_size keeps default 24 (got %d)" % stage._action_popup_font_size) _check(stage._action_popup_emoji_size == 22, "absent action_popup_emoji_size keeps default 22 (got %d)" % stage._action_popup_emoji_size) _check(stage._queue_panel_font_size == 18, "absent queue_panel_font_size keeps default 18 (got %d)" % stage._queue_panel_font_size) _check(stage._rule_panel_font_size == 18, "absent rule_panel_font_size keeps default 18 (got %d)" % stage._rule_panel_font_size) _check(stage._action_editor_font_size == 18, "absent action_editor_font_size keeps default 18 (got %d)" % stage._action_editor_font_size) _check(stage._rule_editor_font_size == 18, "absent rule_editor_font_size keeps default 18 (got %d)" % stage._rule_editor_font_size) _check(stage._panel_row_font_size == 16, "absent panel_row_font_size keeps default 16 (got %d)" % stage._panel_row_font_size) _check(stage._panel_title_font_size == 18, "absent panel_title_font_size keeps default 18 (got %d)" % stage._panel_title_font_size) _check(stage._panel_title_bold == true, "absent panel_title_bold keeps default true (got %s)" % str(stage._panel_title_bold)) _check(stage._rule_label_bold == false, "absent rule_label_bold keeps default false (got %s)" % str(stage._rule_label_bold)) _check(stage._badge_bold == true, "absent badge_bold keeps default true (got %s)" % str(stage._badge_bold)) _check(stage._action_popup_bold == false, "absent action_popup object form keeps default bold false (got %s)" % str(stage._action_popup_bold)) _check(stage._action_popup_italic == false, "absent action_popup object form keeps default italic false (got %s)" % str(stage._action_popup_italic)) await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 5b: apply_font(...) tolerates null fonts and applies bold titles. # --------------------------------------------------------------------------- func _test_apply_font_null_fonts_and_bold_title() -> void: print("") print("--- apply_font: null fonts no-crash + bold title override ---") var stage := _new_stage() # The four Phase 3c widgets are already added by _build_ui; re-applying with # null fonts + an empty sizes dict must be a safe no-op-ish walk. stage._queue_panel.apply_font(null, null, {}) stage._rule_panel.apply_font(null, null, {}) stage._action_editor.apply_font(null, null, {}) stage._rule_editor.apply_font(null, null, {}) _check(true, "apply_font(null, null, {}) runs on all four widgets without error") # With a ui font + bold font configured, the queue panel title label gets the # bold font and the panel_title size; the body walks the base font/size. var base_font := SystemFont.new() var bold_font := SystemFont.new() stage._queue_panel.apply_font(base_font, null, { "queue_panel": 20, "panel_row": 15, "panel_title": 22, "panel_title_bold": true, "bold_font": bold_font, }) _check(stage._queue_panel._title_label.get_theme_font("font") == bold_font, "queue panel title uses the bold font when panel_title_bold is true") _check(stage._queue_panel._title_label.get_theme_font_size("font_size") == 22, "queue panel title uses the panel_title size (got %d)" % stage._queue_panel._title_label.get_theme_font_size("font_size")) _check(stage._queue_panel._empty_label.get_theme_font_size("font_size") == 15, "queue panel empty label uses the row size (got %d)" % stage._queue_panel._empty_label.get_theme_font_size("font_size")) # With panel_title_bold false the title falls back to the ui font. stage._queue_panel.apply_font(base_font, null, { "queue_panel": 20, "panel_row": 15, "panel_title": 22, "panel_title_bold": false, "bold_font": bold_font, }) _check(stage._queue_panel._title_label.get_theme_font("font") == base_font, "queue panel title uses the ui font when panel_title_bold is false") # apply_font must not break row creation: a panel refreshed afterwards still # parents rows and applies the row size. var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) _check(rig != null, "rig spawns for apply_font row test") if rig != null: rig.queue_action({ "type": "wait", "duration": 1.0 }) stage._open_queue_panel(rig) await process_frame _check(stage._queue_panel._list.get_child_count() == 2, "queue rows still build after apply_font (got %d)" % stage._queue_panel._list.get_child_count()) var row: PanelContainer = stage._queue_panel._rows[0] # Row structure: PanelContainer -> HBoxContainer -> [number, summary, edit, remove, drag]. var row_hbox := row.get_child(0) as HBoxContainer var summary_label := row_hbox.get_child(1) as Label _check(summary_label.get_theme_font_size("font_size") == 15, "queue row summary uses the row font size after apply_font (got %d)" % summary_label.get_theme_font_size("font_size")) await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 5c: action_popup_emoji_size is applied by _apply_popup_theme. # --------------------------------------------------------------------------- func _test_popup_emoji_size_applied() -> void: print("") print("--- action_popup_emoji_size applied by _apply_popup_theme ---") var stage := _new_stage() # Baseline: no fonts configured -> popup keeps the action_popup_font_size. var no_font_path := "user://bugfix_theme_no_font.json" _write_theme(no_font_path, { "fonts": { "action_popup_font_size": 24, "action_popup_emoji_size": 22 } }) stage._load_theme(no_font_path) stage._apply_popup_theme(stage._action_popup) _check(stage._action_popup.get_theme_font_size("font_size") == 24, "popup keeps action_popup_font_size when no font is configured (got %d)" % stage._action_popup.get_theme_font_size("font_size")) _check(not stage._action_popup.has_theme_font_override("font"), "popup has no font override when no font is configured") # With an emoji font configured the emoji size becomes the menu font size. var emoji_font := SystemFont.new() stage._emoji_font = emoji_font stage._apply_popup_theme(stage._action_popup) _check(stage._action_popup.has_theme_font_override("font"), "popup uses the configured emoji font") _check(stage._action_popup.get_theme_font_size("font_size") == 22, "action_popup_emoji_size applied as the menu font size (got %d)" % stage._action_popup.get_theme_font_size("font_size")) # A non-default emoji size from the theme also flows through. var sized_path := "user://bugfix_theme_sized.json" _write_theme(sized_path, { "fonts": { "action_popup_emoji_size": 19 } }) stage._load_theme(sized_path) _check(stage._action_popup_emoji_size == 19, "theme action_popup_emoji_size 19 parsed (got %d)" % stage._action_popup_emoji_size) stage._emoji_font = emoji_font stage._apply_popup_theme(stage._action_popup) _check(stage._action_popup.get_theme_font_size("font_size") == 19, "applied emoji size follows the parsed key (got %d)" % stage._action_popup.get_theme_font_size("font_size")) # Style-variant bold flag: bold font wins over the emoji font. var bold_font := SystemFont.new() stage._action_popup_bold = true stage._ui_font_bold = bold_font stage._apply_popup_theme(stage._action_popup) _check(stage._action_popup.get_theme_font("font") == bold_font, "action_popup bold flag selects the bold font") _check(stage._action_popup.get_theme_font_size("font_size") == 19, "emoji size still applied when the bold font is active (got %d)" % stage._action_popup.get_theme_font_size("font_size")) await _free_stage(stage) # --------------------------------------------------------------------------- # Bug 5d: stage_director_visuals.set_style() honours rule_label_bold / badge_bold. # --------------------------------------------------------------------------- func _test_visuals_style_bold_flags() -> void: print("") print("--- StageDirectorVisuals set_style bold flags ---") var stage := _new_stage() var visuals = stage._director_visuals _check(visuals != null, "stage owns a director visuals node") var ui_font := SystemFont.new() var bold_font := SystemFont.new() var emoji_font := SystemFont.new() visuals.ui_font = ui_font visuals.bold_font = bold_font visuals.emoji_font = emoji_font # Defaults from an empty theme block: badge bold on, rule label not bold. visuals.set_style({}) _check(visuals.rule_label_bold == false, "set_style default rule_label_bold false") _check(visuals.badge_bold == true, "set_style default badge_bold true") _check(visuals._rule_label_font() == ui_font, "rule label uses ui_font when rule_label_bold is off") _check(visuals._badge_font() == bold_font, "badge uses the bold font when badge_bold is on") # Bold rule labels + non-bold badges. visuals.set_style({ "fonts": { "rule_label_bold": true, "badge_bold": false } }) _check(visuals.rule_label_bold == true, "set_style honours rule_label_bold true") _check(visuals.badge_bold == false, "set_style honours badge_bold false") _check(visuals._rule_label_font() == bold_font, "rule label uses the bold font when rule_label_bold is on") _check(visuals._badge_font() == emoji_font, "badge falls back to the emoji font when badge_bold is off") # Back off both: rule label returns to ui_font. visuals.set_style({ "fonts": { "rule_label_bold": false, "badge_bold": false } }) _check(visuals._rule_label_font() == ui_font, "rule label returns to ui_font when rule_label_bold toggles off") _check(visuals._badge_font() == emoji_font, "badge stays on the emoji font with badge_bold off") # Bold fallback when no bold font is configured: uses ui/emoji fonts. visuals.bold_font = null visuals.set_style({ "fonts": { "rule_label_bold": true, "badge_bold": true } }) _check(visuals._rule_label_font() == ui_font, "rule label falls back to ui_font when bold_font is null") _check(visuals._badge_font() == emoji_font, "badge falls back to the emoji font when bold_font is null") await _free_stage(stage) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- func _new_stage() -> Node2D: var stage: Node2D = STAGE_SCENE.instantiate() root.add_child(stage) stage._snap_enabled = false return stage func _free_stage(stage: Node2D) -> void: stage.queue_free() await process_frame func _write_theme(path: String, data: Dictionary) -> void: var file := FileAccess.open(path, FileAccess.WRITE) if file != null: file.store_string(JSON.stringify(data, " ", false)) file.close() func _check(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 print("FAIL: " + message)