Files
stickman/tests/test_phase3c_bugfix.gd

621 lines
28 KiB
GDScript

# 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)