243 lines
9.5 KiB
GDScript
243 lines
9.5 KiB
GDScript
# probe_coverage_recorder.gd — 真输入覆盖登记件(探针共用面)
|
||
# 是什么:探针/C 驱动件把合成 InputEvent 经 Viewport.push_input 送进引擎输入管线时,登记
|
||
# 「真输入实际命中了哪些交互控件」,探针收尾落 JSONP 式 .js 到 tests/.gates/coverage/
|
||
# (.js 供 file:// 页面 <script src> 直载——fetch 本地 json 会被 CORS 拦)。
|
||
# 成员面(两源):① 场景树全部 BaseButton——pressed 沿自动接线(重扫懒触发,见下);
|
||
# ② 自绘命中类——现役接 camp_scene_view.object_pressed(信号带物件 id,key 追加 #物件id 后缀),
|
||
# 其余自绘命中面走 note_hit() 显式口。
|
||
# 真输入旗标:push 前置位、帧末清位——只有旗标在位期间的命中入账;信号直发(〔接线断言〕保留站)
|
||
# 与产品码自发射永不入账。命中=控件真的收到事件:hitbox 错位/遮挡时零命中,缺口自然暴露——
|
||
# 这是「新控件落地→覆盖登记自动出现它」的数据源。
|
||
# key 规则(契约「场景文件路径+节点路径」的实况落地偏差,详见 qa-harness 讨论文档回写节):
|
||
# 本仓库 UI 大头由代码构建(main_ui_build/ui_kit 不设节点名),运行时节点路径全为 @类@序
|
||
# 临时名——契约原样落地产出空登记。故键取「锚链归一化」形态:自主根到控件只收集带稳定身份的
|
||
# 锚(具名节点用其名、带脚本节点用脚本文件名;@ 临时名段整体不入键),控件本体记 类#钮面文本,
|
||
# 同键控件按接线遍历序追加 #序位。键在确定构建序下可复现;UI 构建序变更会平移同键序位——
|
||
# 表现为键漂移而非静默丢失,属测量面可接受代价。
|
||
# 用法:探针 _ready 里 `_recorder = ProbeCoverageRecorder.new()` → `attach(main, "<探针名>")`;
|
||
# 收尾 `dump_js()`。探针共用面不入 S 系扫描面(code-conventions §4.13 分档)。
|
||
extends RefCounted
|
||
|
||
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")
|
||
|
||
var _main: Node
|
||
var _probe_name: String
|
||
var _pending := false # 真输入在途旗标
|
||
var _camp: Control = null # 营地场景根(自绘命中单点)
|
||
var _hits: Dictionary = {} # 稳定 key(String) → 命中次数(int)
|
||
var _node_keys: Dictionary = {} # 接线时分配的节点键(instance_id → key)
|
||
var _key_seen: Dictionary = {} # 基键出现次数(同键控件按遍历序追加序位)
|
||
var _wired: Dictionary = {} # 已连 pressed 的按钮实例 id → true(防重连)
|
||
var _skipped_temp := 0 # 临时名命中笔数(不入 key,只计数)
|
||
|
||
|
||
# ---- 装载与接线 ----
|
||
|
||
func attach(main: Node, probe_name: String) -> void:
|
||
_main = main
|
||
_probe_name = probe_name
|
||
ProbeUiUtil.coverage_hook = self # 共用件真输入口统一过旗标(push_click/push_click_at/push_motion)
|
||
_wire_buttons()
|
||
_wire_camp_scene()
|
||
|
||
|
||
# 懒重扫:每次真输入 push 前接线一遍新入树的按钮(开层重建的钮行在下次点击前接上);
|
||
# 已释放按钮的连接随节点释放失效,登记表按 instance_id 清扫。
|
||
func mark_real_input() -> void:
|
||
for id in _wired.keys():
|
||
if instance_from_id(int(id)) == null:
|
||
_wired.erase(id)
|
||
_node_keys.erase(id)
|
||
_wire_buttons()
|
||
if not _pending: # 同帧多次 push 只挂一次帧末清位旗(重复 connect 会报引擎错)
|
||
_pending = true
|
||
_main.get_tree().process_frame.connect(_clear_pending, CONNECT_ONE_SHOT)
|
||
|
||
|
||
func _clear_pending() -> void:
|
||
_pending = false
|
||
|
||
|
||
func _wire_buttons() -> void:
|
||
_wire_into(_main)
|
||
|
||
|
||
func _wire_into(node: Node) -> void:
|
||
if node is BaseButton:
|
||
var id := node.get_instance_id()
|
||
if not _wired.has(id):
|
||
_wired[id] = true
|
||
_node_keys[id] = _assign_key(node)
|
||
(node as BaseButton).pressed.connect(_on_button_pressed.bind(id))
|
||
for child in node.get_children():
|
||
_wire_into(child)
|
||
|
||
|
||
func _wire_camp_scene() -> void:
|
||
_camp = _find_by_script(_main, "camp_scene_view")
|
||
if _camp != null:
|
||
_camp.object_pressed.connect(_on_camp_object)
|
||
|
||
|
||
func _find_by_script(root: Node, script_name: String) -> Control:
|
||
var s: Script = root.get_script()
|
||
if s != null and String(s.resource_path).contains(script_name) and root is Control:
|
||
return root as Control
|
||
for child in root.get_children():
|
||
var found := _find_by_script(child, script_name)
|
||
if found != null:
|
||
return found
|
||
return null
|
||
|
||
|
||
# ---- 键分配(锚链归一化,接线时一次算好存表)----
|
||
|
||
func _assign_key(n: Node) -> String:
|
||
var chain := _anchor_chain(n)
|
||
var leaf := ""
|
||
if n is BaseButton:
|
||
leaf = "%s#%s" % [n.get_class(), String((n as BaseButton).text)]
|
||
else:
|
||
leaf = n.get_class()
|
||
var base := "%s:%s/%s" % [String(_main.scene_file_path), "/".join(chain), leaf]
|
||
# 同键控件按接线遍历序加序位(#1 省略)——重复键可分账,序位随构建序漂移属已知代价
|
||
_key_seen[base] = int(_key_seen.get(base, 0)) + 1
|
||
if int(_key_seen[base]) > 1:
|
||
base += "#%d" % int(_key_seen[base])
|
||
return base
|
||
|
||
|
||
# 自主根到 n 的稳定锚链:具名节点取其名、带脚本节点取脚本文件名;@ 临时名段不入链。
|
||
func _anchor_chain(n: Node) -> Array[String]:
|
||
var chain: Array[String] = []
|
||
var cur: Node = n
|
||
while cur != null and cur != _main:
|
||
var nm := String(cur.name)
|
||
if not nm.begins_with("@"):
|
||
chain.push_front(nm)
|
||
elif cur.get_script() != null:
|
||
var sp := String((cur.get_script() as Script).resource_path)
|
||
chain.push_front(sp.get_file().trim_suffix(".gd"))
|
||
cur = cur.get_parent()
|
||
if chain.is_empty():
|
||
chain.push_front("(root)")
|
||
return chain
|
||
|
||
|
||
# ---- 显式登记口(自绘命中类无信号可连时的单点)----
|
||
|
||
func note_hit(n: Node, suffix: String = "") -> void:
|
||
if not _pending or n == null:
|
||
return
|
||
var id := n.get_instance_id()
|
||
var key := String(_node_keys.get(id, ""))
|
||
if key == "":
|
||
key = _assign_key(n)
|
||
_node_keys[id] = key
|
||
_bump(key + suffix)
|
||
|
||
|
||
# ---- 命中处理 ----
|
||
|
||
func _on_button_pressed(id: int) -> void:
|
||
if not _pending:
|
||
return
|
||
var key := String(_node_keys.get(id, ""))
|
||
if key == "":
|
||
_skipped_temp += 1
|
||
return
|
||
_bump(key)
|
||
|
||
|
||
func _on_camp_object(key: StringName) -> void:
|
||
if not _pending or _camp == null:
|
||
return
|
||
var id := _camp.get_instance_id()
|
||
var base := String(_node_keys.get(id, ""))
|
||
if base == "":
|
||
base = _assign_key(_camp)
|
||
_node_keys[id] = base
|
||
_bump(base + "#" + String(key))
|
||
|
||
|
||
func _bump(key: String) -> void:
|
||
_hits[key] = int(_hits.get(key, 0)) + 1
|
||
|
||
|
||
# ---- 场景树全量交互面枚举(控件缺口数据源:seen−hit=「存在但零真输入覆盖」)----
|
||
# 成员面与登记同源:场景树全部 BaseButton(main 建树常驻——未开过的屏的钮也算存在;
|
||
# 已接线钮复用接线键防序位错位,未接线者落盘期才补键——落盘是终态动作,序位计数此后不再消费)
|
||
# +营地物件(经 db 布局表 hotspot_key 公共数据面枚举;物件命中见 _on_camp_object 的 #物件id 后缀)。
|
||
func enumerate_seen() -> Array[String]:
|
||
var keys: Array[String] = []
|
||
_seen_into(_main, keys)
|
||
var db = _main.get("db")
|
||
if db != null and db.get("presentation") != null:
|
||
var layout = db.presentation.camp_scene_layout
|
||
if layout != null:
|
||
for spec in layout.layers:
|
||
var hk: StringName = spec.hotspot_key
|
||
var ck := "camp_object#" + String(hk)
|
||
if hk != &"" and not keys.has(ck):
|
||
keys.append(ck)
|
||
return keys
|
||
|
||
|
||
func _seen_into(node: Node, keys: Array[String]) -> void:
|
||
if node is BaseButton:
|
||
var id := node.get_instance_id()
|
||
var key := String(_node_keys.get(id, ""))
|
||
if key == "":
|
||
key = _assign_key(node)
|
||
_node_keys[id] = key
|
||
if not keys.has(key):
|
||
keys.append(key)
|
||
for child in node.get_children():
|
||
_seen_into(child, keys)
|
||
|
||
|
||
# ---- 落盘(JSONP 式 .js——file:// 页面 <script src> 直载)----
|
||
|
||
func dump_js() -> void:
|
||
var dir := DirAccess.open("res://")
|
||
if dir == null:
|
||
push_error("[coverage] res:// 打不开,登记未落盘")
|
||
return
|
||
var mk := dir.make_dir_recursive("tests/.gates/coverage")
|
||
if mk != OK and not dir.dir_exists("tests/.gates/coverage"):
|
||
push_error("[coverage] coverage 目录建不了(err=%d),登记未落盘" % mk)
|
||
return
|
||
var path := "res://tests/.gates/coverage/probe_hits_%s.js" % _probe_name
|
||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||
if f == null:
|
||
push_error("[coverage] 登记文件打不开:%s" % path)
|
||
return
|
||
var total := 0
|
||
for k in _hits:
|
||
total += int(_hits[k])
|
||
var seen := enumerate_seen()
|
||
var payload := {
|
||
"meta": {
|
||
"probe": _probe_name,
|
||
"seed": OS.get_environment("PROBE_SEED"),
|
||
"scene": String(_main.scene_file_path),
|
||
"generated_at": Time.get_datetime_string_from_system(),
|
||
"driver": "InputEvent via Viewport.push_input(真输入管线)",
|
||
"key_shape": "锚链归一化(具名/脚本锚+类#文本+序位;@ 临时名不入键)",
|
||
"hits_total": total,
|
||
"seen_total": seen.size(),
|
||
"skipped_temp_names": _skipped_temp,
|
||
},
|
||
"hits": _hits,
|
||
"seen": seen,
|
||
}
|
||
var js := "/* 真输入覆盖登记——探针每次运行自动覆写,人工修订无效(豁免面走人工清单,不落本文件)。 */\n"
|
||
js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
|
||
js += "window.__QA_COVERAGE__.probe_real_input = window.__QA_COVERAGE__.probe_real_input || {};\n"
|
||
js += "window.__QA_COVERAGE__.probe_real_input[%s] = %s;\n" % [
|
||
JSON.stringify(_probe_name), JSON.stringify(payload, " ")]
|
||
f.store_string(js)
|
||
f.close()
|
||
print("[coverage] 真输入命中登记已落盘 %s(控件 %d 处 / 命中 %d 次 / 临时名跳过 %d)" % [
|
||
path, _hits.size(), total, _skipped_temp])
|