From 8c790357d38f9f60f3e0ca9ebec4b50eae11a277 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 3 Jul 2026 01:29:08 -0400 Subject: [PATCH] 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. --- tests/bootstrapper.gd | 70 +++++++++++++++++++++++++ tests/economy.gd | 58 +++++++++++++++++++++ tests/runner.gd | 118 ++++++++++++++++++++++++++++++++++++++++++ tests/team.gd | 58 +++++++++++++++++++++ tests/weapons.gd | 76 +++++++++++++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 tests/bootstrapper.gd create mode 100644 tests/economy.gd create mode 100644 tests/runner.gd create mode 100644 tests/team.gd create mode 100644 tests/weapons.gd diff --git a/tests/bootstrapper.gd b/tests/bootstrapper.gd new file mode 100644 index 0000000..0b8da76 --- /dev/null +++ b/tests/bootstrapper.gd @@ -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 "" diff --git a/tests/economy.gd b/tests/economy.gd new file mode 100644 index 0000000..2ab67c0 --- /dev/null +++ b/tests/economy.gd @@ -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 diff --git a/tests/runner.gd b/tests/runner.gd new file mode 100644 index 0000000..46f4935 --- /dev/null +++ b/tests/runner.gd @@ -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 diff --git a/tests/team.gd b/tests/team.gd new file mode 100644 index 0000000..4142de0 --- /dev/null +++ b/tests/team.gd @@ -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 "" diff --git a/tests/weapons.gd b/tests/weapons.gd new file mode 100644 index 0000000..00b808c --- /dev/null +++ b/tests/weapons.gd @@ -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