Files
transcience/tests/unit/test_ui_theme.gd
T
claude 132646f6c3
ci / verify (push) Successful in 48s
Rework the UI on Crusenho's pack; credit it in the game, not just the repo
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:12:13 +02:00

141 lines
6.1 KiB
GDScript

extends GutTest
## The control theme. A missing texture gives a null and a silently unstyled
## control, which looks exactly like the theme never having been applied -- so
## every path is checked rather than assumed.
func test_every_declared_texture_loads() -> void:
for key in UiTheme.TEXTURES:
var tex := UiTheme.texture(key)
assert_not_null(tex, "%s (%s) did not load" % [key, UiTheme.TEXTURES[key]])
assert_gt(tex.get_width(), 0)
assert_gt(tex.get_height(), 0)
func test_the_theme_styles_the_controls_the_game_actually_builds() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled", "focus"]:
assert_true(t.has_stylebox(state, "Button"), "Button has no %s style" % state)
assert_true(t.has_stylebox("panel", "Panel"))
assert_true(t.has_stylebox("panel", "PanelContainer"))
assert_true(t.has_stylebox("normal", "LineEdit"))
## The pack's surfaces are cream with a black outline, so text on them has to be
## dark. Everything the game draws over the world stays light; only controls
## sitting on this art flip.
func test_text_on_the_packs_surfaces_is_dark() -> void:
var t := UiTheme.get_theme()
for key in ["font_color", "font_hover_color", "font_pressed_color"]:
var c: Color = t.get_color(key, "Button")
assert_lt(c.get_luminance(), 0.35, "%s is too light for cream art" % key)
assert_lt((t.get_color("font_color", "LineEdit") as Color).get_luminance(), 0.35)
## Nine-patch margins are what stop a 1px border smearing across a stretched
## button. Zero margins would render as a plain stretched texture.
func test_button_styles_are_nine_patched() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled"]:
var sb := t.get_stylebox(state, "Button") as StyleBoxTexture
assert_not_null(sb)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
assert_gt(sb.get_texture_margin(side), 0.0,
"%s has no stretch margin on side %d" % [state, side])
## The corner size cannot exceed half the sprite, or the two caps overlap and
## the middle has negative width.
func test_stretch_margins_fit_inside_their_sprites() -> void:
var t := UiTheme.get_theme()
for entry in [["normal", "Button"], ["panel", "Panel"], ["normal", "LineEdit"]]:
var sb := t.get_stylebox(entry[0], entry[1]) as StyleBoxTexture
var size := Vector2(sb.texture.get_size())
assert_lte(sb.get_texture_margin(SIDE_LEFT) + sb.get_texture_margin(SIDE_RIGHT),
size.x, "%s margins exceed its width" % entry[1])
assert_lte(sb.get_texture_margin(SIDE_TOP) + sb.get_texture_margin(SIDE_BOTTOM),
size.y, "%s margins exceed its height" % entry[1])
## Built once and shared. Themes are read-only data, so a second instance would
## be waste rather than isolation.
func test_the_theme_is_built_once() -> void:
assert_eq(UiTheme.get_theme(), UiTheme.get_theme())
## The four button states have to be four different pictures, or the interface
## gives no feedback and the pack's state art is going unused.
func test_the_button_states_are_visually_distinct() -> void:
var seen := {}
for key in ["button", "button_hover", "button_pressed", "button_disabled"]:
seen[UiTheme.TEXTURES[key]] = true
assert_eq(seen.size(), 4, "two button states share a texture")
## The inventory draws slots at exactly twice the source size. A non-integer
## scale on pixel art with nearest filtering gives uneven pixel widths, which on
## a 1px border reads as a wobble along every edge.
func test_the_inventory_slot_is_drawn_at_a_whole_multiple() -> void:
var slot := UiTheme.texture("slot")
var scale := HUD_SLOT / float(slot.get_width())
assert_eq(scale, floorf(scale), "slot art is scaled by %f" % scale)
assert_eq(slot.get_width(), slot.get_height(), "the slot should be square")
const HUD_SLOT := 64.0
## A Control resolves its theme by walking up the parent chain, which is why
## one assignment near the root covers screens that share no parent. Checked on
## the mechanism rather than on a Window, so it runs headless.
func test_the_theme_reaches_controls_through_their_ancestors() -> void:
var root: Control = autofree(Control.new())
root.theme = UiTheme.get_theme()
var middle := Control.new()
root.add_child(middle)
var button := Button.new()
middle.add_child(button)
assert_eq(button.get_theme_stylebox("normal", "Button"),
UiTheme.get_theme().get_stylebox("normal", "Button"),
"a button two levels down should still find the theme")
assert_eq(button.get_theme_color("font_color", "Button"), UiTheme.INK)
func test_applying_to_nothing_is_harmless() -> void:
UiTheme.apply_to(null)
assert_true(true, "a null root must not be a crash")
func test_a_themed_root_fills_its_parent_and_carries_the_theme() -> void:
var root: Control = autofree(UiTheme.themed_root())
assert_eq(root.theme, UiTheme.get_theme())
assert_eq(root.anchor_right, 1.0)
assert_eq(root.anchor_bottom, 1.0)
## Every screen in the game is built in code and hangs off either a plain Node
## or a CanvasLayer, and theme inheritance travels Control-to-Control ONLY --
## the chain breaks at the first non-Control parent. Setting the theme once on
## the Window therefore looked like it worked and styled nothing, which is a
## mistake that is invisible until somebody looks at the screen. So: every
## screen is instantiated here and asked what its buttons actually resolve.
func test_every_screen_actually_gets_the_theme() -> void:
var screens := {
"HUD": "res://src/ui/hud.gd",
"GameMenu": "res://src/ui/game_menu.gd",
"CharacterSelect": "res://src/ui/character_select.gd",
"UpgradeScreen": "res://src/ui/upgrade_screen.gd",
"SettingsScreen": "res://src/ui/settings_screen.gd",
"CreditsScreen": "res://src/ui/credits_screen.gd",
"MainMenu": "res://src/ui/main_menu.gd",
}
var wanted := UiTheme.get_theme().get_stylebox("normal", "Button")
for name in screens:
var node: Node = (load(screens[name]) as Script).new()
add_child_autofree(node)
var buttons := node.find_children("*", "Button", true, false)
assert_gt(buttons.size(), 0, "%s has no button to check" % name)
for b: Button in buttons:
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
"%s: '%s' is not themed" % [name, b.text])