# test_text_baseline_fix.gd # Headless regression test for two text-baseline drawing fixes: # 1. scripts/stickman_speech_bubble.gd _draw() text baseline now includes # font.get_ascent(FONT_SIZE) so glyphs sit inside the bubble rect. # 2. scripts/stage_director_visuals.gd _draw_rule() rule-label baseline now # includes font.get_ascent(font_size) so white text sits on the dark rect. # # The math is replicated against the real ThemeDB.fallback_font using each # file's actual constants (read off the script resources, not copied here), so # the test tracks any future constant/geometry changes and proves the OLD # (buggy) formula would fail the same containment checks (discriminator). # # Run with: # & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_text_baseline_fix.gd --path . # # Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL. extends SceneTree const SpeechBubbleScript := preload("res://scripts/stickman_speech_bubble.gd") const StageDirectorVisualsScript := preload("res://scripts/stage_director_visuals.gd") # Inline constants from stage_director_visuals.gd's _draw_rule() (they are not # named constants there). RULE_LABEL_FONT_SIZE_PX is read from the script. const RULE_LABEL_PADDING := Vector2(6.0, 4.0) const RULE_ZOOM := 1.0 var _checks := 0 var _failures := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: print("") print("========================================================") print(" TEXT BASELINE REGRESSION TEST (headless)") print("========================================================") _test_speech_bubble() _test_rule_label() 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: SpeechBubble._draw() # --------------------------------------------------------------------------- func _test_speech_bubble() -> void: print("") print("--- SpeechBubble._draw() baseline (bug 1) ---") var bubble: Node2D = SpeechBubbleScript.new() root.add_child(bubble) # Long enough that, wrapped at MAX_WIDTH=320 @ FONT_SIZE=28, the string # spans multiple lines (proves the vertical-layout path is exercised). var text := "Hello there! This is a nice long speech bubble message that wraps over multiple lines, just like a real line of dialogue." bubble.show_text(text) var font := ThemeDB.fallback_font var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, SpeechBubbleScript.MAX_WIDTH, SpeechBubbleScript.FONT_SIZE) var box_size := text_size + SpeechBubbleScript.PADDING * 2.0 var box := Rect2(Vector2(-box_size.x * 0.5, -SpeechBubbleScript.TAIL_HEIGHT - box_size.y), box_size) var ascent := font.get_ascent(SpeechBubbleScript.FONT_SIZE) var descent := font.get_descent(SpeechBubbleScript.FONT_SIZE) # Fixed formula (current code in _draw()). var fixed_baseline := Vector2( box.position.x + SpeechBubbleScript.PADDING.x, box.position.y + SpeechBubbleScript.PADDING.y + ascent ) # Old (buggy) formula: draw_string(font, box.position + PADDING, ...). var old_baseline := box.position + SpeechBubbleScript.PADDING # Prove the probe string genuinely exceeds the wrap width (and therefore the # production box is clamped to MAX_WIDTH). Note: in Godot 4.4 # get_string_size() caps the width at the given width but reports a # single-line height, so "wrap exercised" is proven via the natural width # exceeding MAX_WIDTH and the box width equaling MAX_WIDTH. var natural_width := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, SpeechBubbleScript.FONT_SIZE).x _check(natural_width > SpeechBubbleScript.MAX_WIDTH, "SpeechBubble: probe string natural width (%.1f) exceeds MAX_WIDTH (%.1f) -> wrap path exercised" % [natural_width, SpeechBubbleScript.MAX_WIDTH]) _check(text_size.x <= SpeechBubbleScript.MAX_WIDTH + 0.001, "SpeechBubble: box width is clamped to MAX_WIDTH (%.1f)" % text_size.x) _check(fixed_baseline.y - ascent >= box.position.y - 0.001, "SpeechBubble: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y]) _check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001, "SpeechBubble: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y]) _check(old_baseline.y - ascent < box.position.y - 0.001, "SpeechBubble: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y]) print(" box=%s text_size=%s ascent=%.2f descent=%.2f" % [box, text_size, ascent, descent]) print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline]) bubble.free() # --------------------------------------------------------------------------- # Bug 2: StageDirectorVisuals._draw_rule() label baseline # --------------------------------------------------------------------------- func _test_rule_label() -> void: print("") print("--- StageDirectorVisuals._draw_rule() label baseline (bug 2) ---") var visuals: Node2D = StageDirectorVisualsScript.new() root.add_child(visuals) # A realistic When->Then rule whose summary exercises two actions. var rule := { "id": 1, "trigger": {"type": "arrived_at_waypoint", "params": {"waypoint_pos": Vector2(100.0, 0.0)}, "source": 0}, "actions": [ {"type": "speak", "params": {"text": "Hello there!"}, "target": 0}, {"type": "wait", "params": {"duration": 5.0}, "target": 0}, ], } var summary := StageDirectorVisualsScript.rule_summary(rule) var zoom := RULE_ZOOM var font := ThemeDB.fallback_font var font_size := int(StageDirectorVisualsScript.RULE_LABEL_FONT_SIZE_PX / zoom) var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) var padding := RULE_LABEL_PADDING / zoom var mid := Vector2(50.0, 50.0) var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0) var ascent := font.get_ascent(font_size) var descent := font.get_descent(font_size) # Fixed formula (current code in _draw_rule()). var fixed_baseline := box.position + padding + Vector2(0.0, ascent) # Old (buggy) formula: draw_string(font, box.position + padding, ...). var old_baseline := box.position + padding _check(fixed_baseline.y - ascent >= box.position.y - 0.001, "RuleLabel: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y]) _check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001, "RuleLabel: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y]) _check(old_baseline.y - ascent < box.position.y - 0.001, "RuleLabel: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y]) print(" summary='%s'" % summary) print(" box=%s text_size=%s font_size=%d ascent=%.2f descent=%.2f" % [box, text_size, font_size, ascent, descent]) print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline]) visuals.free() func _check(condition: bool, message: String) -> void: _checks += 1 if condition: print("PASS: " + message) else: _failures += 1 print("FAIL: " + message)