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]) ## A Slider draws its groove in a band whose thickness is the stylebox's own ## minimum size -- which for a StyleBoxTexture is its content margins. With ## those at zero the track resolves, reports the right texture, and draws ## nothing, which is exactly what it did. func test_slider_styleboxes_have_a_thickness() -> void: var t := UiTheme.get_theme() for key in ["slider", "grabber_area", "grabber_area_highlight"]: var sb := t.get_stylebox(key, "HSlider") assert_gt(sb.get_minimum_size().y, 0.0, "HSlider/%s would draw with no height" % key) ## The filled portion has to sit inside the groove, not overhang it. func test_the_filled_portion_is_no_thicker_than_the_groove() -> void: var t := UiTheme.get_theme() assert_lte(t.get_stylebox("grabber_area", "HSlider").get_minimum_size().y, t.get_stylebox("slider", "HSlider").get_minimum_size().y) func test_the_slider_grabber_is_big_enough_to_aim_at() -> void: var grab := UiTheme.get_theme().get_icon("grabber", "HSlider") assert_not_null(grab) assert_gte(grab.get_width(), 12, "an 8px grabber is a speck at this scale") assert_lte(grab.get_height(), 26, "and one taller than its row overflows it") ## Nearest-neighbour and a whole multiple, or the pixel art turns to mush. func test_scaling_a_sprite_keeps_it_a_whole_multiple() -> void: var src := UiTheme.texture("grabber") var out := UiTheme.scaled("grabber", 2) assert_eq(out.get_width(), src.get_width() * 2) assert_eq(out.get_height(), src.get_height() * 2) ## Every scrollbar piece has to be styled or the default grey chrome shows ## through on the two screens that scroll. func test_scrollbars_are_styled() -> void: var t := UiTheme.get_theme() for bar_class in ["VScrollBar", "HScrollBar"]: for key in ["scroll", "grabber", "grabber_highlight", "grabber_pressed"]: assert_true(t.has_stylebox(key, bar_class), "%s/%s is unstyled" % [bar_class, key]) ## And they need a thickness, for the same reason sliders do: a ScrollBar sizes ## itself from its styleboxes' minimum size, so styling one with no content ## margins produces a bar zero pixels wide -- which looks exactly like having ## no scrollbar, and did. func test_scrollbars_have_a_thickness_in_both_directions() -> void: var t := UiTheme.get_theme() for bar_class in ["VScrollBar", "HScrollBar"]: for key in ["scroll", "grabber"]: var size := t.get_stylebox(key, bar_class).get_minimum_size() assert_gt(size.x, 0.0, "%s/%s has no width" % [bar_class, key]) assert_gt(size.y, 0.0, "%s/%s has no height" % [bar_class, key]) ## Panels are the pack's shape in this game's colours: the art is cream, and a ## cream dialog over a dark dungeon would need every label in five screens ## recoloured. Tinting the stylebox keeps the border and the existing text. func test_dialog_panels_are_tinted_dark() -> void: var sb := UiTheme.get_theme().get_stylebox("panel", "PanelContainer") as StyleBoxTexture assert_lt(sb.modulate_color.get_luminance(), 0.35, "a bright panel would make every light label on it unreadable") assert_gt(sb.get_minimum_size().x, 0.0, "a dialog needs padding") func test_a_card_panel_carries_its_tint() -> void: var card: PanelContainer = autofree(UiTheme.card_panel(Color(0.9, 0.2, 0.2))) var sb := card.get_theme_stylebox("panel") as StyleBoxTexture assert_not_null(sb) assert_almost_eq(sb.modulate_color.r, 0.9, 0.001)