Split the multiplayer test harness into modules
mp_test_driver.gd was 1458 lines doing six unrelated jobs. It is now orchestration only — the scripted sequence, the RPC plumbing between peers, and the manual keyboard controls — with the work in modules that each have one: MpWorldView finding things in the world and describing what they are doing MpSteps the simulated player actions (reach, grab, carry, drop) MpAsserts the per-step checks MpSnapshot the cross-peer sync audit MpReport the ledger, the log, the overlay, the screenshots The scenario list and the audit logic carry over unchanged. That audit compares what is actually RENDERED on both peers, not just the replicated values behind it, which is the only thing that catches a plate whose contents arrived but whose visuals were never rebuilt — so it was worth moving verbatim. Assertions that tested the old model now test the new one: "who is holding this" is the authority of the object's NetXform, not of the object itself. Suite: 146/146 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
extends Node
|
||||
class_name MpReport
|
||||
|
||||
## Everything the harness writes down: the ledger of checks, the run log, the
|
||||
## on-screen overlay, and the per-step screenshots.
|
||||
##
|
||||
## Kept apart from the test logic so a step never has to think about where its
|
||||
## output goes — it returns a verdict, and this decides how that is recorded.
|
||||
|
||||
## Lines kept in the on-screen overlay. Enough to show a whole step without the
|
||||
## panel covering the kitchen underneath it.
|
||||
const OVERLAY_LINES := 11
|
||||
|
||||
## Frames are captured at half the viewport's resolution: 60-odd full-size frames
|
||||
## per peer is a lot of pixels to write and then re-encode.
|
||||
const FRAME_SCALE := 0.5
|
||||
|
||||
## Every check, in order: {step, side, ok, detail}.
|
||||
var results: Array[Dictionary] = []
|
||||
|
||||
var _log_file: FileAccess
|
||||
var _log_path := ""
|
||||
var _overlay: Label
|
||||
var _overlay_lines: Array[String] = []
|
||||
|
||||
var _frames_enabled := false
|
||||
var _frames_dir := ""
|
||||
var _frame_index := 0
|
||||
var _frame_labels: Array[String] = []
|
||||
|
||||
|
||||
func setup(is_server: bool, overlay_parent: Node) -> void:
|
||||
var role := "server" if is_server else "client"
|
||||
_log_path = "res://logs/mptest_%s.log" % role
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs"))
|
||||
_log_file = FileAccess.open(_log_path, FileAccess.WRITE)
|
||||
_build_overlay(overlay_parent)
|
||||
|
||||
|
||||
# --- log -------------------------------------------------------------------
|
||||
|
||||
func log_line(s: String) -> void:
|
||||
print("[MPTEST] %s" % s)
|
||||
if _log_file:
|
||||
_log_file.store_line(s)
|
||||
_log_file.flush()
|
||||
_push_overlay(s)
|
||||
|
||||
|
||||
func banner(s: String) -> void:
|
||||
log_line("")
|
||||
log_line("======== %s ========" % s)
|
||||
|
||||
|
||||
func _build_overlay(parent: Node) -> void:
|
||||
if not parent:
|
||||
return
|
||||
var layer := CanvasLayer.new()
|
||||
parent.add_child(layer)
|
||||
_overlay = Label.new()
|
||||
_overlay.add_theme_font_size_override("font_size", 14)
|
||||
_overlay.add_theme_color_override("font_color", Color.WHITE)
|
||||
_overlay.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
_overlay.add_theme_constant_override("outline_size", 6)
|
||||
_overlay.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||
layer.add_child(_overlay)
|
||||
|
||||
|
||||
func _push_overlay(s: String) -> void:
|
||||
if not _overlay:
|
||||
return
|
||||
_overlay_lines.append(s)
|
||||
while _overlay_lines.size() > OVERLAY_LINES:
|
||||
_overlay_lines.pop_front()
|
||||
_overlay.text = "\n".join(_overlay_lines)
|
||||
|
||||
|
||||
# --- ledger ----------------------------------------------------------------
|
||||
|
||||
func record(step: String, side: String, res: Dictionary) -> void:
|
||||
var ok: bool = res.get("ok", false)
|
||||
var detail: String = str(res.get("detail", ""))
|
||||
results.append({"step": step, "side": side, "ok": ok, "detail": detail})
|
||||
log_line("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step])
|
||||
log_line(" %s" % detail)
|
||||
|
||||
|
||||
func failed_count() -> int:
|
||||
var failed := 0
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
failed += 1
|
||||
return failed
|
||||
|
||||
|
||||
func print_summary() -> void:
|
||||
banner("RESULTS")
|
||||
for r in results:
|
||||
log_line("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
||||
var failed := failed_count()
|
||||
log_line("%d/%d checks passed" % [results.size() - failed, results.size()])
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
log_line("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
||||
|
||||
|
||||
## A standalone report written next to the logs, so a run can be read without
|
||||
## scrolling the console — and so the in-editor runner can print it back.
|
||||
func write_report() -> void:
|
||||
var path := "res://logs/mptest_report.txt"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if not f:
|
||||
return
|
||||
var failed := failed_count()
|
||||
var passed := results.size() - failed
|
||||
f.store_line("VRyHungry multiplayer test report")
|
||||
f.store_line("run at %s" % Time.get_datetime_string_from_system())
|
||||
f.store_line("")
|
||||
f.store_line("RESULT: %s (%d passed, %d failed, %d total)"
|
||||
% ["ALL CHECKS PASSED" if failed == 0 else "FAILED", passed, failed, results.size()])
|
||||
f.store_line("")
|
||||
if failed > 0:
|
||||
f.store_line("--- failures ---")
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
f.store_line("FAIL [%s] %s" % [r["side"], r["step"]])
|
||||
f.store_line(" %s" % r["detail"])
|
||||
f.store_line("")
|
||||
f.store_line("--- every check, in order ---")
|
||||
for r in results:
|
||||
f.store_line("%-4s %-6s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
||||
f.close()
|
||||
log_line("report written to %s" % ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
# --- per-step screenshots --------------------------------------------------
|
||||
#
|
||||
# One frame per peer per step, stitched into a side-by-side GIF afterwards (see
|
||||
# test/make_gif.ps1). Seeing both peers' viewports next to each other for the
|
||||
# same step is the fastest way to spot a visual desync: the log tells you
|
||||
# something diverged, the GIF shows you what it looked like.
|
||||
|
||||
func setup_frames(enabled: bool, is_server: bool) -> void:
|
||||
_frames_enabled = enabled
|
||||
if not _frames_enabled:
|
||||
return
|
||||
if DisplayServer.get_name() == "headless":
|
||||
_frames_enabled = false
|
||||
log_line("frame capture disabled: a headless run has no rendered output to grab")
|
||||
return
|
||||
_frames_dir = "res://logs/mptest_frames_%s" % ("server" if is_server else "client")
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
|
||||
# Clear a previous run's frames, or the GIF splices the two together.
|
||||
var dir := DirAccess.open(_frames_dir)
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.ends_with(".png"):
|
||||
dir.remove(f)
|
||||
log_line("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
|
||||
|
||||
|
||||
func frames_enabled() -> bool:
|
||||
return _frames_enabled
|
||||
|
||||
|
||||
func next_frame_index() -> int:
|
||||
_frame_index += 1
|
||||
return _frame_index
|
||||
|
||||
|
||||
func save_frame(index: int, label: String) -> void:
|
||||
if not _frames_enabled:
|
||||
return
|
||||
_frame_index = index
|
||||
# Wait for the frame to actually be drawn, or we capture whatever was in the
|
||||
# buffer before this step's changes landed.
|
||||
await RenderingServer.frame_post_draw
|
||||
var tex := get_viewport().get_texture()
|
||||
if not tex:
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if not img:
|
||||
return
|
||||
if FRAME_SCALE != 1.0:
|
||||
img.resize(int(img.get_width() * FRAME_SCALE), int(img.get_height() * FRAME_SCALE),
|
||||
Image.INTERPOLATE_BILINEAR)
|
||||
# Index-only filenames so ffmpeg's image sequence reader picks them up as
|
||||
# frame_%04d.png; the step name is already legible in the overlay.
|
||||
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
|
||||
if err != OK:
|
||||
log_line(" could not save frame %d (%s)" % [index, error_string(err)])
|
||||
else:
|
||||
_frame_labels.append("%04d %s" % [index, label])
|
||||
|
||||
|
||||
## Written alongside the frames so a frame number can be traced back to the step
|
||||
## that produced it.
|
||||
func write_frame_index() -> void:
|
||||
if not _frames_enabled or _frame_labels.is_empty():
|
||||
return
|
||||
var f := FileAccess.open("%s/frames.txt" % _frames_dir, FileAccess.WRITE)
|
||||
if f:
|
||||
for line in _frame_labels:
|
||||
f.store_line(line)
|
||||
f.close()
|
||||
Reference in New Issue
Block a user