spaceship/tests/test_menu_price_unification.gd
2026-08-31 02:18:08 +02:00

192 lines
8.6 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_menu_price_unification.gd
# Forgejo #137: "the resources at the top are not always shown the same way,
# the price is not always put at the same place... the build menu, the
# research menu, the ship crafter menu would benefit from a more unified
# UX." And #134, which is the same complaint aimed at the skill panel: the
# explanation could land on top of the price, and a shortfall was a sentence
# instead of a number.
#
# The point of the ticket is CONSISTENCY, which is a property of several
# things at once - so this test compares the menus against each other rather
# than against written-down coordinates. A menu that moves is fine; a menu
# that moves away from the others is the bug.
#
# Covers:
# A. every buying menu has a resource bar, in the same place, the same size
# B. every price is the same widget - one class, not three presentations
# C. a price says what you HAVE as well as what it COSTS, with the held
# figure smaller than the price (#134) and red when it is short
# D. #134 - the skill panel's explanation is left of its price, not on top
# of it, and the two do not overlap at any description length
# E. the shortfall is no longer also spelled out in a sentence
const PRICED_MENUS: Array = ["BuildMenu", "CraftMenu", "LabMenu"]
# Compared by SCRIPT PATH, as a string, and that is not fussiness.
# ResourceBar and PriceTag both read the GameState autoload, and an
# `extends SceneTree` script that so much as pulls one of them in - naming
# the class, or preloading the file - fails to compile, because GameState is
# not an identifier in this context. Worse, GDScript caches that failure, so
# the scene instantiated a few lines below would get a Control with no script
# where its resource bar should be. A string names the file without asking
# for it, and "is this the same script" is the question anyway.
const RESOURCE_BAR_PATH: String = "res://ui/ResourceBar.gd"
const PRICE_TAG_PATH: String = "res://ui/PriceTag.gd"
var game_state: Node
var scene: Node
var controller: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.rocks = 40
func _fail(message: String) -> bool:
print("TEST_MENU_PRICE_UNIFICATION: FAIL (%s)" % message)
quit(1)
return true
func _script_path(node: Node) -> String:
var script: Script = node.get_script()
return "" if script == null else script.resource_path
# The RESOLVED rectangle, not the offsets. Two Controls with different
# anchors have offsets measured from different edges, so comparing those
# compares nothing - and subtracting them produces negative sizes, which
# Rect2.intersects refuses outright.
func _rect(node: Control) -> Rect2:
return node.get_rect()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
controller = scene.get_node("BuildController")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
# --- A. the same bar, in the same place ------------------
var bars: Dictionary = {}
for menu in PRICED_MENUS:
var bar: Control = scene.get_node_or_null("UI/%s/Bar" % menu)
if bar == null:
return _fail("%s has no resource bar - the other menus do" % menu)
if _script_path(bar) != RESOURCE_BAR_PATH:
return _fail("%s's bar is not a ResourceBar" % menu)
bars[menu] = bar
var reference: Rect2 = _rect(bars[PRICED_MENUS[0]])
for menu in PRICED_MENUS:
var here: Rect2 = _rect(bars[menu])
if here != reference:
return _fail("%s's resource bar sits at %s, the others at %s" \
% [menu, here, reference])
if bars[menu].anchor_right != bars[PRICED_MENUS[0]].anchor_right:
return _fail("%s's resource bar is anchored differently" % menu)
print("%d menus, one resource bar geometry: %s" % [PRICED_MENUS.size(), reference])
# --- B. one price widget everywhere ----------------------
var tags: Array = []
for entry in controller.build_menu.entries:
tags.append(entry.cost)
tags.append_array(controller.craft_menu.price_tags)
tags.append_array(controller.lab_menu.price_tags)
tags.append(controller.build_menu.info_cost)
tags.append(controller.skill_menu.cost)
if tags.size() < 15:
return _fail("only %d price widgets found - the menus cannot all be covered" % tags.size())
for tag in tags:
if _script_path(tag) != PRICE_TAG_PATH:
return _fail("a price is drawn by %s, not the shared price tag" \
% _script_path(tag))
print("%d prices across the build, craft, lab and skill menus, all one widget" % tags.size())
# --- C. have over cost, smaller, red when short ----------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 3)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0)
var probe: Control = controller.build_menu.info_cost
probe.show_price({GameState.RESOURCE_ORE: 25})
if probe.priced(GameState.RESOURCE_ORE) != 25:
return _fail("the tag does not carry the price it was given")
if probe.held(GameState.RESOURCE_ORE) != 3:
return _fail("the tag reads %d held, the player has 3" \
% probe.held(GameState.RESOURCE_ORE))
if not probe.is_short(GameState.RESOURCE_ORE):
return _fail("3 out of 25 is not marked short")
if probe.is_affordable():
return _fail("a tag the player cannot pay reads as affordable")
# Read off the labels the player actually sees, rather than off
# the constants behind them.
var entry: Dictionary = probe._entry_for(GameState.RESOURCE_ORE)
var have_size: int = entry.have_label.get_theme_font_size("font_size")
var cost_size: int = entry.cost_label.get_theme_font_size("font_size")
if have_size >= cost_size:
return _fail("what you hold (size %d) is not drawn smaller than the price (size %d)" \
% [have_size, cost_size])
var short_colour: Color = entry.have_label.get_theme_color("font_color")
if short_colour.r <= short_colour.g or short_colour.r <= short_colour.b:
return _fail("the short figure is drawn in %s, which is not a warning colour" % short_colour)
print("price tag: %d/%d ore, held figure at size %d in %s, price at size %d" \
% [probe.held(GameState.RESOURCE_ORE), probe.priced(GameState.RESOURCE_ORE),
have_size, short_colour, cost_size])
# ...and the affordable case is not red, or the colour says nothing.
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 900)
probe.show_price({GameState.RESOURCE_ORE: 25})
if probe.is_short(GameState.RESOURCE_ORE) or not probe.is_affordable():
return _fail("900 ore does not cover a price of 25")
if probe._entry_for(GameState.RESOURCE_ORE).have_label \
.get_theme_color("font_color") == short_colour:
return _fail("an affordable price is still drawn in the shortfall colour")
print("and an affordable price is drawn differently")
# --- D. #134: the explanation is beside the price --------
var longest: String = ""
for node in GameState.SKILL_TREE:
if String(node.desc).length() > longest.length():
longest = String(node.desc)
controller._refresh_skill_menu(String(node.id))
var detail: Rect2 = _rect(controller.skill_menu.detail)
var price: Rect2 = _rect(controller.skill_menu.cost)
if detail.intersects(price):
return _fail("the explanation (%s) overlaps the price (%s)" % [detail, price])
if detail.position.x >= price.position.x:
return _fail("the explanation is not left of the price")
print("skill panel: explanation at x=%.0f, price at x=%.0f, no overlap (longest of %d descriptions)" \
% [detail.position.x, price.position.x, GameState.SKILL_TREE.size()])
# --- E. and the shortfall is not also a sentence ---------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.unlocked.clear()
controller._refresh_skill_menu("shipyards")
if controller.skill_menu.cost.is_affordable():
return _fail("a node reads as affordable with no ore at all")
if controller.skill_menu.status.text.to_lower().contains("not enough"):
return _fail("the shortfall is still written out as well: '%s'" \
% controller.skill_menu.status.text)
print("the shortfall is shown once, on the price, and not repeated in words")
print("TEST_MENU_PRICE_UNIFICATION: PASS")
quit(0)
return true
return false