spaceship/tests/test_menu_price_unification.gd

192 lines
8.5 KiB
GDScript3
Raw Normal View History

#136, #137, #134: one build grid that cannot stack, one way to show a price #136 - the gas burner drawn on top of the transport hub. The build menu's rows were a written-down list of tops read with `tops[mini(row, size - 1)]`, and with every structure researched the menu wanted a fourth row where the list had three, so the last entry was placed on the third row's centre slot. A list that silently stacks whatever will not fit in it is not a layout. Rows are computed now, from a first top and a pitch that tightens to fit and is floored at the row's own height - so two rows cannot occupy the same space however far the catalog grows - and the column count widens rather than adding a row the band cannot hold. Ten structures now sit in four columns of three rows. The test unlocks the whole tree and checks every pair of entries for overlap, which is the shape of the bug rather than a coordinate that happens to be right today. #137 - the game had three answers to "what does this cost". The build menu drew a NeonNumber under each option, the craft menu drew glyph strips, and the ship lab spelled prices out inside the caption. PriceTag is that answer once: the resource's glyph, then what you HOLD over what it COSTS. It replaces all three, plus the info popup and the skill panel, and VesselPriceStrip - which was the same idea for one menu - goes with them. The build menu also gains the resource bar the other two already had, in the same place and at the same size, and the craft menu's - the only one not running the full width of its panel - now matches. The bar drops its LOCAL column where there is no building to report on, for the same reason #95 dropped it where it only repeated BANKED. #134 - the skill panel's explanation and its price were both centred in the same band, so a long description landed on the number. The text is the left of the band now and the tag is the right. The shortfall it used to spell out in a sentence - one per case, ore or research points - is the red on the tag instead, and research points ride along as an entry of their own, so the tag says WHICH thing is missing without a line of prose per possibility. One trap worth recording: PriceTag and ResourceBar read the GameState autoload, and an `extends SceneTree` test script that so much as names such a class - or preloads the file - fails to compile, because GameState is not an identifier in that context. GDScript then caches the failure, so the scene the test instantiates gets a Control with no script where its resource bar should be. The tests compare script PATHS, as strings.
2026-08-28 15:05:20 +02:00
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[3])
tags.append_array(controller.craft_price_strips)
tags.append_array(controller.lab_price_tags)
tags.append(controller.info_cost)
tags.append(controller.skill_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.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_detail)
var price: Rect2 = _rect(controller.skill_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_cost.is_affordable():
return _fail("a node reads as affordable with no ore at all")
if controller.skill_status.text.to_lower().contains("not enough"):
return _fail("the shortfall is still written out as well: '%s'" \
% controller.skill_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