391 lines
14 KiB
GDScript
391 lines
14 KiB
GDScript
extends SceneTree
|
||
## Headless-тест GameController: полный цикл prep → hour → hour_result → prep,
|
||
## магазин, 9 часов до run_result, save/load и continue_run.
|
||
## Запуск: godot --headless --path . --script tests/test_controller.gd
|
||
|
||
const GC := preload("res://scripts/game_controller.gd")
|
||
const SM := preload("res://scripts/systems/save_system.gd")
|
||
|
||
var _checks: int = 0
|
||
var _fails: int = 0
|
||
var _case: String = ""
|
||
|
||
var _game = null
|
||
var _c = null
|
||
|
||
var _hour_events: int = 0
|
||
var _run_events: int = 0
|
||
var _shop_events: int = 0
|
||
var _phase_events: int = 0
|
||
var _last_report: Dictionary = {}
|
||
var _last_run: Dictionary = {}
|
||
|
||
var _had_save: bool = false
|
||
var _backup: String = ""
|
||
|
||
|
||
func _init() -> void:
|
||
_backup_save()
|
||
if SM.has_save():
|
||
SM.delete_save()
|
||
await process_frame
|
||
_game = root.get_node_or_null("Game")
|
||
if _game == null:
|
||
_fail("autoload Game недоступен")
|
||
_finish()
|
||
return
|
||
_c = GC.new()
|
||
_c.name = "GameController"
|
||
root.add_child(_c)
|
||
await process_frame
|
||
_c.hour_finished.connect(_on_hour_finished)
|
||
_c.run_finished.connect(_on_run_finished)
|
||
_c.shop_changed.connect(_on_shop_changed)
|
||
_c.phase_changed.connect(_on_phase_changed)
|
||
|
||
_test_empty_continue()
|
||
_test_new_run()
|
||
_test_hour_start_and_modal()
|
||
_test_auto_send()
|
||
_test_fast_hour()
|
||
_test_after_hour()
|
||
_test_buy()
|
||
_test_full_run()
|
||
_test_save_load()
|
||
_finish()
|
||
|
||
|
||
# --- кейсы -----------------------------------------------------------------
|
||
|
||
func _test_empty_continue() -> void:
|
||
_set_case("empty_continue")
|
||
_ok(not _c.has_save(), "сейва нет")
|
||
_ok(not _c.continue_run(), "continue_run без сейва → false")
|
||
_eq(String(_c.phase), "menu", "фаза осталась menu")
|
||
|
||
|
||
func _test_new_run() -> void:
|
||
_set_case("new_run")
|
||
_c.new_run(1)
|
||
_eq(String(_c.phase), "prep", "new_run → prep")
|
||
_eq(int(_game.money), 150, "стартовые деньги")
|
||
_approx(float(_game.sla), 128.0, 0.0001, "стартовый SLA")
|
||
_eq(int(_game.hour), 1, "час 1")
|
||
_eq(String(_game.phase), "prep", "Game.phase = prep")
|
||
_ok(_c.has_save(), "автосейв на входе в prep")
|
||
|
||
var prep: Dictionary = _c.prep_data()
|
||
_eq(int(prep.get("money", 0)), 150, "prep_data.money")
|
||
_approx(float(prep.get("sla", 0.0)), 128.0, 0.0001, "prep_data.sla")
|
||
_eq(int(prep.get("hour", 0)), 1, "prep_data.hour")
|
||
_ok(prep.get("upgrades") is Dictionary, "prep_data.upgrades")
|
||
|
||
var sections = prep.get("sections", [])
|
||
_ok(sections is Array and not (sections as Array).is_empty(), "есть секции")
|
||
var sec_c: Dictionary = _find_section(sections, "C")
|
||
_ok(not sec_c.is_empty(), "есть секция C")
|
||
if sec_c.is_empty():
|
||
return
|
||
var folders = sec_c.get("folders", [])
|
||
_ok(folders is Array and not (folders as Array).is_empty(), "в C есть папка")
|
||
if folders is Array and not (folders as Array).is_empty():
|
||
var folder: Dictionary = folders[0]
|
||
_eq(str(folder.get("name", "")), "Разное", "стартовая папка «Разное»")
|
||
var folder_id := str(folder.get("id", ""))
|
||
var files: Dictionary = prep.get("files", {})
|
||
_ok(files.has(folder_id), "prep_data.files содержит папку")
|
||
_ok(files.get(folder_id) is Array, "файлы папки — массив")
|
||
|
||
|
||
func _test_hour_start_and_modal() -> void:
|
||
_set_case("hour_start")
|
||
_ok(_c.start_hour(), "start_hour")
|
||
_eq(String(_c.phase), "hour", "фаза hour")
|
||
_ok(bool(_c.hour_director.running), "HourDirector.running")
|
||
_ok(not _c.start_hour(), "повторный start_hour → false")
|
||
_c.tick(0.1)
|
||
|
||
var hd = _c.hour_director
|
||
var hd_data: Dictionary = _c.hour_data()
|
||
var status: Dictionary = hd_data.get("status", {})
|
||
_eq(int(status.get("tcp_max", 0)), 2, "2 TCP-слота на старте")
|
||
_eq(int(status.get("buffer_max", 0)), 4, "буфер 4 на старте")
|
||
_eq(int(status.get("hour", 0)), 1, "статус: час 1")
|
||
_ok(float(status.get("time_left", 0.0)) > 299.0, "статус: таймер идёт")
|
||
|
||
# Пакет и запрос, чтобы проверить hour_data и заморозку модалкой.
|
||
_set_case("hour_data")
|
||
var packet_id := str(hd.spawn_packet_now("text", "tcp"))
|
||
_ok(not packet_id.is_empty(), "пакет заспавнен")
|
||
var folder_id := _first_folder_id()
|
||
var file = _game.registry.create_file("Тест контроллера", "text", folder_id)
|
||
_ok(file != null, "файл создан")
|
||
var req_id := str(hd.spawn_request_now())
|
||
_ok(not req_id.is_empty(), "запрос создан")
|
||
_c.tick(0.1)
|
||
hd_data = _c.hour_data()
|
||
_eq((hd_data.get("packets", []) as Array).size(), 1, "hour_data: 1 пакет")
|
||
_eq((hd_data.get("requests", []) as Array).size(), 1, "hour_data: 1 запрос")
|
||
_eq((hd_data.get("slots", []) as Array).size(), 2, "hour_data: 2 слота")
|
||
if not (hd_data.get("packets", []) as Array).is_empty():
|
||
_eq(str(hd_data["packets"][0].get("id", "")), packet_id, "hour_data: id пакета")
|
||
|
||
_set_case("modal")
|
||
var req = hd.active_requests.get(req_id)
|
||
_ok(req != null, "запрос в активных")
|
||
if req == null:
|
||
return
|
||
var req_time := float(req.time_left)
|
||
_c.set_modal_open(true)
|
||
_ok(bool(hd.modal_open), "директор видит модалку")
|
||
var packets_before: int = hd.active_packets.size()
|
||
var accum_before: float = float(hd.spawn_recv_accum)
|
||
for i in range(10):
|
||
_c.tick(0.1)
|
||
_eq(hd.active_packets.size(), packets_before, "модалка: пакеты не спавнятся")
|
||
_approx(float(hd.spawn_recv_accum), accum_before, 0.0001, "модалка: спавн-аккумулятор стоит")
|
||
_approx(float(req.time_left), req_time - 1.0, 0.02, "модалка: таймер запроса идёт")
|
||
_c.set_modal_open(false)
|
||
_ok(not hd.modal_open, "модалка закрыта")
|
||
hd.reject_request(req_id)
|
||
_ok(not hd.active_requests.has(req_id), "запрос убран после reject")
|
||
|
||
|
||
func _test_auto_send() -> void:
|
||
_set_case("auto_send")
|
||
var hd = _c.hour_director
|
||
_ok(hd != null and bool(hd.running), "час идёт")
|
||
if hd == null:
|
||
return
|
||
var file = _game.registry.create_file("Авто контроллер", "text", _first_folder_id())
|
||
_ok(file != null, "файл для автоотправки создан")
|
||
if file == null:
|
||
return
|
||
var req_id := str(hd._make_request([file], "udp", false, "", ""))
|
||
var req = hd.active_requests.get(req_id)
|
||
_ok(req != null, "запрос создан")
|
||
if req == null:
|
||
return
|
||
_ok(hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||
_ok(bool(req.sending), "UDP: отправка началась сразу после attach_file")
|
||
_ok(not bool(req.minigame_active), "minigame_active не поднимается")
|
||
_ok(not bool(hd.modal_open), "модалки нет")
|
||
var progress_before := float(req.send_progress)
|
||
_c.tick(0.1)
|
||
_ok(float(req.send_progress) > progress_before, "send_progress идёт")
|
||
hd._cleanup_request(req_id, true)
|
||
|
||
|
||
func _test_fast_hour() -> void:
|
||
_set_case("fast_hour")
|
||
_c.hour_director.set_time_scale(60.0)
|
||
for i in range(100):
|
||
# Изолируем переход фазы от автопотерь SLA в ускоренном прогоне.
|
||
_game.sla = 200.0
|
||
_c.tick(0.1)
|
||
if String(_c.phase) != "hour":
|
||
break
|
||
_eq(String(_c.phase), "hour_result", "час завершён → hour_result")
|
||
_eq(_hour_events, 1, "hour_finished эмитится один раз")
|
||
_ok(not _last_report.is_empty(), "отчёт часа не пустой")
|
||
_eq(int(_last_report.get("hour", 0)), 1, "в отчёте час 1")
|
||
_ok(_last_report.has("earned"), "в отчёте есть earned")
|
||
_ok(_last_report.has("completed"), "в отчёте есть completed")
|
||
_ok(_last_report.has("failed"), "в отчёте есть failed")
|
||
_ok(_last_report.has("sla_delta"), "в отчёте есть sla_delta")
|
||
_eq(String(_game.phase), "hour", "Game.phase ещё hour до next_after_hour")
|
||
_eq(int(_game.hour), 1, "Game.hour ещё 1 до next_after_hour")
|
||
_ok(not _c.current_report.is_empty(), "current_report заполнен")
|
||
|
||
|
||
func _test_after_hour() -> void:
|
||
_set_case("next_after_hour")
|
||
_c.next_after_hour()
|
||
_eq(String(_c.phase), "prep", "после отчёта → prep")
|
||
_eq(String(_game.phase), "prep", "Game.phase = prep")
|
||
_eq(int(_game.hour), 2, "следующий час = 2")
|
||
_ok(_c.has_save(), "автосейв после часа")
|
||
# Повторный next_after_hour в prep ничего не двигает.
|
||
_c.next_after_hour()
|
||
_eq(int(_game.hour), 2, "next_after_hour вне hour_result игнорируется")
|
||
|
||
|
||
func _test_buy() -> void:
|
||
_set_case("buy")
|
||
_game.money = 1000
|
||
var shop_before: int = _shop_events
|
||
_ok(_c.buy("shop_slot3"), "покупка третьего слота")
|
||
_eq(int(_game.money), 750, "списано 250")
|
||
_eq(int(_game.upgrades.get("slot_level", 0)), 1, "slot_level = 1")
|
||
_eq(_shop_events, shop_before + 1, "shop_changed эмитится")
|
||
_ok(not _c.buy("shop_slot3"), "повторная покупка → false")
|
||
_eq(int(_game.money), 750, "деньги не списаны повторно")
|
||
_eq((_c.shop_data() as Array).size(), 10, "shop_data: 10 позиций")
|
||
|
||
|
||
func _test_full_run() -> void:
|
||
_set_case("full_run")
|
||
_c.new_run(4242)
|
||
_eq(int(_game.hour), 1, "новый забег с часа 1")
|
||
_eq(int(_game.money), 150, "деньги нового забега")
|
||
|
||
var hours_before: int = _hour_events
|
||
var runs_before: int = _run_events
|
||
for h in range(1, 10):
|
||
_ok(_c.start_hour(), "старт часа %d" % h)
|
||
_eq(String(_c.phase), "hour", "фаза hour в часе %d" % h)
|
||
var hd = _c.hour_director
|
||
hd.set_time_scale(60.0)
|
||
# Изоляция механики контроллера: без массовых спавнов ускоренный
|
||
# прогон не должен упираться в SLA. Атаки остаются штатными.
|
||
hd.recv_disabled_until = 9999.0
|
||
hd.send_disabled_until = 9999.0
|
||
var finished := false
|
||
for i in range(100):
|
||
_game.sla = 200.0
|
||
_c.tick(0.1)
|
||
if String(_c.phase) != "hour":
|
||
finished = true
|
||
break
|
||
_ok(finished, "час %d завершился" % h)
|
||
_eq(String(_c.phase), "hour_result", "час %d: hour_result" % h)
|
||
_c.next_after_hour()
|
||
if h < 9:
|
||
_eq(String(_c.phase), "prep", "час %d: prep" % h)
|
||
_eq(int(_game.hour), h + 1, "час %d: следующий" % h)
|
||
_eq(_hour_events - hours_before, 9, "9 отчётов часов")
|
||
_eq(_run_events - runs_before, 1, "run_finished эмитится один раз")
|
||
_eq(String(_c.phase), "run_result", "9-й час → run_result")
|
||
_eq(String(_game.phase), "results", "Game.phase = results")
|
||
|
||
var result: Dictionary = _c.run_result()
|
||
_ok(not result.is_empty(), "run_result не пустой")
|
||
_ok(bool(result.get("won", false)), "победа при SLA > 0")
|
||
_eq(str(result.get("reason", "")), "victory", "reason = victory")
|
||
_eq(int(result.get("hours", 0)), 9, "hours = 9")
|
||
_eq(int(result.get("money_earned", -1)), 0, "money_earned без отправок = 0")
|
||
_ok(int(result.get("score", 0)) > 0, "score > 0")
|
||
_ok(int(result.get("best", 0)) >= int(result.get("score", 0)), "best >= score")
|
||
_ok(not _last_run.is_empty(), "run_finished пришёл с данными")
|
||
_eq(int(_last_run.get("score", -1)), int(result.get("score", 0)), "сигнал = run_result()")
|
||
# Награды выданы по ходу часов, но итоговый экран должен их показать.
|
||
var unlocked: Array = result.get("unlocked", [])
|
||
_ok(unlocked.has("complete_run"), "unlocked: complete_run на победе")
|
||
_ok(unlocked.has("survive_hour_3") and unlocked.has("survive_hour_5"),
|
||
"unlocked: все награды текущего забега")
|
||
_eq(unlocked.size(), 3, "unlocked без дублей после победы")
|
||
_eq((_last_run.get("unlocked", []) as Array).size(), 3, "unlocked в сигнале run_finished")
|
||
|
||
|
||
func _test_save_load() -> void:
|
||
_set_case("save_load")
|
||
_game.money = 321
|
||
_ok(_c.save(), "save()")
|
||
_ok(_c.has_save(), "has_save()")
|
||
_game.money = 7
|
||
_ok(_c.load(), "load()")
|
||
_eq(int(_game.money), 321, "деньги восстановлены")
|
||
|
||
_set_case("continue_run")
|
||
_ok(_c.continue_run(), "continue_run с сейвом")
|
||
_eq(String(_c.phase), "prep", "continue_run → prep")
|
||
_eq(String(_game.phase), "prep", "Game.phase = prep после continue_run")
|
||
_eq(int(_game.money), 321, "continue_run восстановил деньги")
|
||
|
||
|
||
# --- обработчики -----------------------------------------------------------
|
||
|
||
func _on_hour_finished(report: Dictionary) -> void:
|
||
_hour_events += 1
|
||
_last_report = report
|
||
|
||
|
||
func _on_run_finished(report: Dictionary) -> void:
|
||
_run_events += 1
|
||
_last_run = report
|
||
|
||
|
||
func _on_shop_changed() -> void:
|
||
_shop_events += 1
|
||
|
||
|
||
func _on_phase_changed(_phase: StringName) -> void:
|
||
_phase_events += 1
|
||
|
||
|
||
# --- служебное -------------------------------------------------------------
|
||
|
||
func _first_folder_id() -> String:
|
||
var section = _game.registry.sections.get("C")
|
||
if section == null or section.folder_ids.is_empty():
|
||
return ""
|
||
return str(section.folder_ids[0])
|
||
|
||
|
||
func _find_section(sections: Array, id: String) -> Dictionary:
|
||
for section in sections:
|
||
if section is Dictionary and str(section.get("id", "")) == id:
|
||
return section
|
||
return {}
|
||
|
||
|
||
func _backup_save() -> void:
|
||
_had_save = SM.has_save()
|
||
if _had_save:
|
||
_backup = FileAccess.get_file_as_string(SM.SAVE_PATH)
|
||
|
||
|
||
func _restore_save() -> void:
|
||
if _had_save:
|
||
var file = FileAccess.open(SM.SAVE_PATH, FileAccess.WRITE)
|
||
if file != null:
|
||
file.store_string(_backup)
|
||
file.flush()
|
||
file.close()
|
||
else:
|
||
SM.delete_save()
|
||
if FileAccess.file_exists(SM.TMP_PATH):
|
||
DirAccess.remove_absolute(SM.TMP_PATH)
|
||
|
||
|
||
# --- assert-хелперы --------------------------------------------------------
|
||
|
||
func _set_case(name: String) -> void:
|
||
_case = name
|
||
|
||
|
||
func _ok(condition: bool, message: String) -> bool:
|
||
_checks += 1
|
||
if condition:
|
||
return true
|
||
_fail(message)
|
||
return false
|
||
|
||
|
||
func _eq(actual, expected, message: String) -> bool:
|
||
_checks += 1
|
||
if actual == expected:
|
||
return true
|
||
_fail("%s — получено %s, ожидалось %s" % [message, str(actual), str(expected)])
|
||
return false
|
||
|
||
|
||
func _approx(actual: float, expected: float, eps: float, message: String) -> bool:
|
||
_checks += 1
|
||
if absf(float(actual) - float(expected)) <= eps:
|
||
return true
|
||
_fail("%s — получено %.6f, ожидалось %.6f" % [message, actual, expected])
|
||
return false
|
||
|
||
|
||
func _fail(message: String) -> void:
|
||
_fails += 1
|
||
print("FAIL [%s] %s" % [_case, message])
|
||
|
||
|
||
func _finish() -> void:
|
||
_restore_save()
|
||
print("test_controller: проверок %d, провалов %d" % [_checks, _fails])
|
||
print("PASS test_controller" if _fails == 0 else "FAIL test_controller")
|
||
quit(0 if _fails == 0 else 1)
|