class_name PlayableReport ## 联网测试进程内的报告构建器。 ## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 const SCHEMA_VERSION := 1 const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] var _report: Dictionary = {} var _started_us := 0 func begin(run_id: String, suite: String, config: Dictionary) -> void: _started_us = Time.get_ticks_usec() _report = { "schema_version": SCHEMA_VERSION, "run_id": run_id, "suite": suite, "status": "BLOCKED", "build": _build_info(), "environment": _environment_info(), "cases": [], "events": "events.jsonl", "failures": [], "blocked": [], "coverage": {"required": 0, "passed": 0}, "exit_gate": {"checked": false, "process_code": null, "errors": []}, } # Keep configuration useful for reproducing a run without copying any secret. _report["scenario_id"] = String(config.get("scenario_id", "")) func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void: var normalized := status.to_upper() if not VALID_STATUSES.has(normalized): normalized = "FAIL" _fail("invalid case status for %s" % case_id) var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, "reason": reason, "evidence": _sanitize(evidence)} _report.cases.append(item) _report.coverage.required = int(_report.coverage.required) + 1 if normalized == "PASS": _report.coverage.passed = int(_report.coverage.passed) + 1 elif normalized == "FAIL": _fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"]) elif normalized == "BLOCKED": _report.blocked.append(reason if not reason.is_empty() else case_id) func add_event(event: Dictionary, events_path: String) -> void: var safe: Variant = _sanitize(event) var file := FileAccess.open(events_path, FileAccess.READ_WRITE) if file == null: file = FileAccess.open(events_path, FileAccess.WRITE) if file == null: _fail("cannot open events file") return file.seek_end() file.store_line(JSON.stringify(safe)) file.close() func finish() -> Dictionary: if _report.is_empty(): return {} var required := int(_report.coverage.required) var passed := int(_report.coverage.passed) var failures: Array = _report.failures if not failures.is_empty(): _report.status = "FAIL" elif required == 0 or passed != required or not _report.blocked.is_empty(): _report.status = "BLOCKED" else: _report.status = "PASS" _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0 return _report func write(path: String) -> bool: var report := finish() if report.is_empty(): return false var file := FileAccess.open(path, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(report, " ")) file.close() return true func _fail(message: String) -> void: _report.failures.append(message) func _build_info() -> Dictionary: return { "engine_sha256": OS.get_environment("MT_BUILD_ENGINE_SHA256"), "extension_sha256": OS.get_environment("MT_BUILD_EXTENSION_SHA256"), "pck_sha256": OS.get_environment("MT_BUILD_PCK_SHA256"), "arch": OS.get_environment("MT_BUILD_ARCH"), } func _environment_info() -> Dictionary: var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO return { "os": OS.get_name(), "renderer": RenderingServer.get_video_adapter_name(), "resolution": [size.x, size.y], } func _sanitize(value: Variant) -> Variant: if value is Dictionary: var out := {} for key in value: var name := String(key).to_lower() if name in ["password", "passwd", "token", "secret", "account"]: out[key] = "[redacted]" else: out[key] = _sanitize(value[key]) return out if value is Array: var out_array: Array = [] for item in value: out_array.append(_sanitize(item)) return out_array if value is Vector2: return [value.x, value.y] if value is Vector3: return [value.x, value.y, value.z] if value is Object: return "[object]" return value