Add automated test framework and 21 regression tests
- tests/runner.gd: lightweight headless test runner (TAP format output) - tests/weapons.gd: 6 tests for weapon registry and stats - tests/team.gd: 5 tests for team assignment and balancing - tests/economy.gd: 6 tests for economy constants and loss streaks - tests/bootstrapper.gd: 4 tests for LAN bootstrapper input parsing Run with: godot --headless -s tests/runner.gd --path . All 21 tests pass on Linux and Windows.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
extends RefCounted
|
||||
|
||||
## Bootstrapper network input tests.
|
||||
## Tests the input parsing and validation in lan-bootstrapper.gd.
|
||||
|
||||
const Bootstrapper := preload("res://examples/shared/scripts/lan-bootstrapper.gd")
|
||||
|
||||
func test_empty_address_rejected() -> String:
|
||||
var bp := Bootstrapper.new()
|
||||
bp.address_input = LineEdit.new()
|
||||
bp.port_input = LineEdit.new()
|
||||
bp.connect_ui = Control.new()
|
||||
|
||||
bp.address_input.text = ""
|
||||
bp.port_input.text = "34201"
|
||||
|
||||
var result = bp._parse_input()
|
||||
if not result.is_empty():
|
||||
return "Empty address should return empty dict"
|
||||
return ""
|
||||
|
||||
func test_valid_input_parsed() -> String:
|
||||
var bp := Bootstrapper.new()
|
||||
bp.address_input = LineEdit.new()
|
||||
bp.port_input = LineEdit.new()
|
||||
bp.connect_ui = Control.new()
|
||||
|
||||
bp.address_input.text = "192.168.0.127"
|
||||
bp.port_input.text = "34201"
|
||||
|
||||
var result = bp._parse_input()
|
||||
if result.is_empty():
|
||||
return "Valid input should return filled dict"
|
||||
if result.address != "192.168.0.127":
|
||||
return "Address mismatch"
|
||||
if result.port != 34201:
|
||||
return "Port mismatch: got %d" % result.port
|
||||
return ""
|
||||
|
||||
func test_invalid_port_rejected() -> String:
|
||||
var bp := Bootstrapper.new()
|
||||
bp.address_input = LineEdit.new()
|
||||
bp.port_input = LineEdit.new()
|
||||
bp.connect_ui = Control.new()
|
||||
|
||||
bp.address_input.text = "192.168.0.127"
|
||||
bp.port_input.text = "not-a-number"
|
||||
|
||||
var result = bp._parse_input()
|
||||
if not result.is_empty():
|
||||
return "Non-numeric port should return empty dict"
|
||||
return ""
|
||||
|
||||
func test_localhost_accepted() -> String:
|
||||
var bp := Bootstrapper.new()
|
||||
bp.address_input = LineEdit.new()
|
||||
bp.port_input = LineEdit.new()
|
||||
bp.connect_ui = Control.new()
|
||||
|
||||
bp.address_input.text = "localhost"
|
||||
bp.port_input.text = "34197"
|
||||
|
||||
var result = bp._parse_input()
|
||||
if result.is_empty():
|
||||
return "localhost should be valid"
|
||||
if result.address != "localhost":
|
||||
return "Address should be 'localhost'"
|
||||
if result.port != 34197:
|
||||
return "Port 34197 should be parsed"
|
||||
return ""
|
||||
@@ -0,0 +1,58 @@
|
||||
extends RefCounted
|
||||
|
||||
## Economy system tests — verifies constants, money clamping, loss streaks.
|
||||
|
||||
const Economy := preload("res://examples/multiplayer-fps/scripts/economy_manager.gd")
|
||||
|
||||
func test_economy_constants() -> String:
|
||||
var e := ""
|
||||
if Economy.START_MONEY != 800: e += " START_MONEY should be 800"
|
||||
if Economy.MAX_MONEY != 16000: e += " MAX_MONEY should be 16000"
|
||||
if Economy.WIN_REWARD != 3250: e += " WIN_REWARD should be 3250"
|
||||
if Economy.LOSS_BASE != 1400: e += " LOSS_BASE should be 1400"
|
||||
if Economy.LOSS_INCREMENT != 500: e += " LOSS_INCREMENT should be 500"
|
||||
if Economy.LOSS_MAX != 3400: e += " LOSS_MAX should be 3400"
|
||||
return e
|
||||
|
||||
func test_equipment_prices() -> String:
|
||||
var e := ""
|
||||
if Economy.KEVLAR_PRICE != 650: e += " Kevlar price wrong"
|
||||
if Economy.DEFUSE_KIT_PRICE != 400: e += " Defuse kit price wrong"
|
||||
if Economy.FLASH_PRICE != 200: e += " Flash price wrong"
|
||||
if Economy.SMOKE_PRICE != 300: e += " Smoke price wrong"
|
||||
return e
|
||||
|
||||
func test_grenade_limits() -> String:
|
||||
var e := ""
|
||||
if Economy.FLASH_MAX != 2: e += " MAX flashbangs should be 2"
|
||||
if Economy.SMOKE_MAX != 1: e += " MAX smokes should be 1"
|
||||
return e
|
||||
|
||||
func test_money_clamp_to_max() -> String:
|
||||
var em := EconomyManager.new()
|
||||
em._money[1] = 0
|
||||
# Simulate add_money bypassing multiplayer check
|
||||
em._money[1] = clampi(em._money.get(1, 0) + 99999, 0, 16000)
|
||||
if em._money[1] != 16000:
|
||||
return "Money should clamp at 16000, got %d" % em._money[1]
|
||||
return ""
|
||||
|
||||
func test_money_clamp_to_zero() -> String:
|
||||
var em := EconomyManager.new()
|
||||
em._money[1] = 500
|
||||
em._money[1] = clampi(em._money.get(1, 0) - 99999, 0, 16000)
|
||||
if em._money[1] != 0:
|
||||
return "Money should clamp at 0, got %d" % em._money[1]
|
||||
return ""
|
||||
|
||||
func test_loss_streak_formula() -> String:
|
||||
var e := ""
|
||||
var streak_0 := mini(1400 + 0 * 500, 3400)
|
||||
if streak_0 != 1400: e += " 0-loss streak should be 1400"
|
||||
var streak_1 := mini(1400 + 1 * 500, 3400)
|
||||
if streak_1 != 1900: e += " 1-loss streak should be 1900"
|
||||
var streak_4 := mini(1400 + 4 * 500, 3400)
|
||||
if streak_4 != 3400: e += " 4-loss streak should be 3400 (capped)"
|
||||
var streak_10 := mini(1400 + 10 * 500, 3400)
|
||||
if streak_10 != 3400: e += " 10-loss streak should be 3400 (capped)"
|
||||
return e
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env godot -s
|
||||
extends SceneTree
|
||||
|
||||
## Self-contained test runner for tactical-shooter.
|
||||
## No framework dependencies.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless -s tests/runner.gd --path .
|
||||
##
|
||||
## Discovers test scripts, instantiates them, runs methods
|
||||
## beginning with "test_", and reports in TAP format.
|
||||
|
||||
func _init():
|
||||
print("TAP version 14")
|
||||
print("1..0 # Starting test discovery")
|
||||
print("# _init reached")
|
||||
|
||||
var files := _find_files("res://tests", "*.gd", ["test_util.gd", "runner.gd"])
|
||||
print("# Found %d test file(s)" % files.size())
|
||||
|
||||
var total := 0
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
for f in files:
|
||||
var r := _run_script(f)
|
||||
total += r.total
|
||||
passed += r.passed
|
||||
failed += r.failed
|
||||
failures.append_array(r.failures)
|
||||
|
||||
# Re-print header with real count
|
||||
print("1..%d" % total)
|
||||
|
||||
for f in failures:
|
||||
print("# FAIL %s" % f)
|
||||
|
||||
if failed > 0:
|
||||
print("# %d of %d tests failed" % [failed, total])
|
||||
quit(1)
|
||||
else:
|
||||
print("# All %d tests passed!" % total)
|
||||
quit(0)
|
||||
|
||||
# Result from running a test script
|
||||
class RunResult:
|
||||
var total := 0
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _find_files(dir_path: String, pattern: String, excludes: Array[String]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
push_error("Cannot open directory: %s" % dir_path)
|
||||
return result
|
||||
dir.list_dir_begin()
|
||||
var fname := dir.get_next()
|
||||
while fname != "":
|
||||
if fname.ends_with(".gd") and not fname in excludes and not fname.begins_with("."):
|
||||
result.append(dir_path.path_join(fname))
|
||||
fname = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return result
|
||||
|
||||
func _run_script(path: String) -> RunResult:
|
||||
var r := RunResult.new()
|
||||
var script := load(path)
|
||||
if script == null:
|
||||
push_error("Cannot load: %s" % path)
|
||||
return r
|
||||
|
||||
if not script.has_method("new"):
|
||||
push_error("Cannot instantiate: %s" % path)
|
||||
return r
|
||||
|
||||
var obj = script.new()
|
||||
var methods := _get_test_methods(script)
|
||||
|
||||
print("# Running: %s (%d tests)" % [path, methods.size()])
|
||||
|
||||
for m in methods:
|
||||
var test_name := m.trim_prefix("test_").replace("_", " ").capitalize()
|
||||
r.total += 1
|
||||
|
||||
var ok := true
|
||||
var err_msg := ""
|
||||
var callable = Callable(obj, m)
|
||||
var result = callable.call()
|
||||
if result is String and result.length() > 0:
|
||||
ok = false
|
||||
err_msg = result
|
||||
|
||||
if ok:
|
||||
r.passed += 1
|
||||
print("ok %d - %s" % [r.total, test_name])
|
||||
else:
|
||||
r.failed += 1
|
||||
print("not ok %d - %s" % [r.total, test_name])
|
||||
var file_line := "(unknown)"
|
||||
if obj.has_method("_get_current_line"):
|
||||
file_line = str(obj._get_current_line())
|
||||
r.failures.append("%s::%s: %s" % [path.get_file(), m, err_msg])
|
||||
|
||||
if not obj is RefCounted:
|
||||
obj.free()
|
||||
return r
|
||||
|
||||
func _get_test_methods(script: Script) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var methods := script.get_script_method_list()
|
||||
for m in methods:
|
||||
var name := m["name"] as String
|
||||
if name.begins_with("test_") and name != "_init":
|
||||
result.append(name)
|
||||
return result
|
||||
@@ -0,0 +1,58 @@
|
||||
extends RefCounted
|
||||
|
||||
## Team management tests.
|
||||
## Team enum: NONE=-1, T=0, CT=1
|
||||
|
||||
const TeamManager := preload("res://examples/multiplayer-fps/scripts/team-manager.gd")
|
||||
|
||||
func test_team_enum_values() -> String:
|
||||
var e := ""
|
||||
if TeamManager.Team.NONE != -1: e += " NONE should be -1"
|
||||
if TeamManager.Team.T != 0: e += " T should be 0"
|
||||
if TeamManager.Team.CT != 1: e += " CT should be 1"
|
||||
return e
|
||||
|
||||
func test_get_team_name() -> String:
|
||||
var tm := TeamManager.new()
|
||||
var e := ""
|
||||
if tm.get_team_name(TeamManager.Team.T) != "Terrorists":
|
||||
e += " T team name wrong"
|
||||
if tm.get_team_name(TeamManager.Team.CT) != "Counter-Terrorists":
|
||||
e += " CT team name wrong"
|
||||
if tm.get_team_name(TeamManager.Team.NONE) != "Unassigned":
|
||||
e += " NONE team name wrong"
|
||||
return e
|
||||
|
||||
func test_empty_assignments() -> String:
|
||||
var tm := TeamManager.new()
|
||||
if tm.get_team(999) != TeamManager.Team.NONE:
|
||||
return "Unknown peer should return NONE"
|
||||
if tm.get_team_members(TeamManager.Team.T).size() != 0:
|
||||
return "No T members yet"
|
||||
return ""
|
||||
|
||||
func test_auto_assign_balances() -> String:
|
||||
var tm := TeamManager.new()
|
||||
tm.assignments[101] = TeamManager.Team.T
|
||||
tm.assignments[102] = TeamManager.Team.CT
|
||||
|
||||
# T=1, CT=1 — next goes to T (first alphabetically when tied)
|
||||
var team_a := tm.auto_assign(103)
|
||||
if team_a != TeamManager.Team.T:
|
||||
return "3rd peer should join T when tied, got " + str(team_a)
|
||||
|
||||
# T=2, CT=1 — next goes to CT
|
||||
var team_b := tm.auto_assign(104)
|
||||
if team_b != TeamManager.Team.CT:
|
||||
return "4th peer should join CT when T has fewer, got " + str(team_b)
|
||||
return ""
|
||||
|
||||
func test_auto_assign_to_undersized_team() -> String:
|
||||
var tm := TeamManager.new()
|
||||
tm.assignments[101] = TeamManager.Team.CT
|
||||
tm.assignments[102] = TeamManager.Team.CT
|
||||
# T=0, CT=2
|
||||
var team := tm.auto_assign(103)
|
||||
if team != TeamManager.Team.T:
|
||||
return "Peer should join T (0 members) over CT (2 members), got " + str(team)
|
||||
return ""
|
||||
@@ -0,0 +1,76 @@
|
||||
extends RefCounted
|
||||
|
||||
## Weapon registry tests — no framework dependencies, no class_name types.
|
||||
## Each test_* method returns "" on pass or an error message on fail.
|
||||
##
|
||||
## Uses direct integer enum values to avoid class_name loading order issues:
|
||||
## FireMode: SEMI=0, AUTO=1, MELEE=2
|
||||
|
||||
const Registry := preload("res://examples/multiplayer-fps/scripts/data/weapon_registry.gd")
|
||||
|
||||
const KNIFE := 1
|
||||
const GLOCK := 2
|
||||
const USP := 3
|
||||
const AK47 := 4
|
||||
const M4A1 := 5
|
||||
const AWP := 6
|
||||
|
||||
func _check_field(w, field: String, expected) -> String:
|
||||
var actual = w.get(field)
|
||||
if actual != expected:
|
||||
return " %s: got %s, expected %s" % [field, str(actual), str(expected)]
|
||||
return ""
|
||||
|
||||
func test_all_weapons_registered() -> String:
|
||||
var ids := [KNIFE, GLOCK, USP, AK47, M4A1, AWP]
|
||||
for id in ids:
|
||||
var w := Registry.get_weapon(id)
|
||||
if w == null:
|
||||
return "Weapon ID %d not registered" % id
|
||||
return ""
|
||||
|
||||
func test_unknown_id_returns_null() -> String:
|
||||
if Registry.get_weapon(0) != null: return "ID 0 should return null"
|
||||
if Registry.get_weapon(99) != null: return "ID 99 should return null"
|
||||
if Registry.get_weapon(-1) != null: return "ID -1 should return null"
|
||||
return ""
|
||||
|
||||
func test_ak47_stats() -> String:
|
||||
var w := Registry.get_weapon(AK47)
|
||||
if w == null: return "AK47 not found"
|
||||
var e := ""
|
||||
e += _check_field(w, "weapon_name", "AK-47")
|
||||
e += _check_field(w, "damage", 36)
|
||||
e += _check_field(w, "fire_mode", 1) # AUTO=1
|
||||
e += _check_field(w, "team", 0) # T=0
|
||||
e += _check_field(w, "price", 2500)
|
||||
e += _check_field(w, "slot", 2)
|
||||
return e
|
||||
|
||||
func test_awp_stats() -> String:
|
||||
var w := Registry.get_weapon(AWP)
|
||||
if w == null: return "AWP not found"
|
||||
var e := ""
|
||||
e += _check_field(w, "weapon_name", "AWP")
|
||||
e += _check_field(w, "damage", 110)
|
||||
e += _check_field(w, "fire_mode", 0) # SEMI=0
|
||||
e += _check_field(w, "price", 4750)
|
||||
return e
|
||||
|
||||
func test_knife_stats() -> String:
|
||||
var w := Registry.get_weapon(KNIFE)
|
||||
if w == null: return "Knife not found"
|
||||
var e := ""
|
||||
e += _check_field(w, "weapon_name", "Knife")
|
||||
e += _check_field(w, "damage", 50)
|
||||
e += _check_field(w, "fire_mode", 2) # MELEE=2
|
||||
e += _check_field(w, "price", 0)
|
||||
return e
|
||||
|
||||
func test_default_pistol() -> String:
|
||||
var e := ""
|
||||
var t := Registry.get_default_pistol(0)
|
||||
if t != GLOCK: e += " T side should get Glock, got %d" % t
|
||||
var ct := Registry.get_default_pistol(1)
|
||||
if ct != USP: e += " CT side should get USP, got %d" % ct
|
||||
return e
|
||||
Reference in New Issue
Block a user