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()