openpuppet-language 2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. openpuppet_language-2.0.data/data/share/puppet/conformance/README.md +274 -0
  2. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/binding.json +238 -0
  3. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/capabilities.json +155 -0
  4. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/grammar.json +248 -0
  5. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/interaction.json +220 -0
  6. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/listen.json +133 -0
  7. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/platform.json +274 -0
  8. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/probes.json +117 -0
  9. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/render.json +180 -0
  10. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/tabs.json +39 -0
  11. openpuppet_language-2.0.data/data/share/puppet/conformance/cases/templates.json +143 -0
  12. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_bad_result.py +11 -0
  13. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_basic.py +24 -0
  14. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_deps_mismatch.py +11 -0
  15. openpuppet_language-2.0.data/data/share/puppet/conformance/fixtures/capabilities_no_doc.py +10 -0
  16. openpuppet_language-2.0.data/data/share/puppet/conformance/runner.py +721 -0
  17. openpuppet_language-2.0.data/data/share/puppet/spec/01-grammar.md +283 -0
  18. openpuppet_language-2.0.data/data/share/puppet/spec/02-ir.md +77 -0
  19. openpuppet_language-2.0.data/data/share/puppet/spec/03-semantics.md +167 -0
  20. openpuppet_language-2.0.data/data/share/puppet/spec/04-vocabulary.md +227 -0
  21. openpuppet_language-2.0.data/data/share/puppet/spec/05-render-contract.md +126 -0
  22. openpuppet_language-2.0.data/data/share/puppet/spec/06-diagnostics.md +151 -0
  23. openpuppet_language-2.0.data/data/share/puppet/spec/README.md +73 -0
  24. openpuppet_language-2.0.dist-info/METADATA +50 -0
  25. openpuppet_language-2.0.dist-info/RECORD +40 -0
  26. openpuppet_language-2.0.dist-info/WHEEL +5 -0
  27. openpuppet_language-2.0.dist-info/entry_points.txt +2 -0
  28. openpuppet_language-2.0.dist-info/top_level.txt +1 -0
  29. puppet/__init__.py +19 -0
  30. puppet/adapter.py +112 -0
  31. puppet/capabilities.py +172 -0
  32. puppet/cli.py +132 -0
  33. puppet/diag.py +42 -0
  34. puppet/engine.py +1038 -0
  35. puppet/eval.py +243 -0
  36. puppet/ir.py +538 -0
  37. puppet/lang.py +755 -0
  38. puppet/raster_adapter.py +348 -0
  39. puppet/tk_adapter.py +374 -0
  40. puppet/vocab.py +147 -0
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env python3
2
+ """Puppet 语言标准 · conformance 运行器(零依赖,仅标准库)。
3
+
4
+ 用法:
5
+ python conformance/runner.py --check
6
+ python conformance/runner.py --impl "<启动实现的命令>"
7
+ python conformance/runner.py --impl "..." --filter b-cycle
8
+
9
+ 实现协议见同目录 README.md 第 4 节。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import time
20
+
21
+ HERE = os.path.dirname(os.path.abspath(__file__))
22
+ DEFAULT_CASES = os.path.join(HERE, "cases")
23
+
24
+ EXPECT_KEYS = {
25
+ "diagnostics", "forbid", "minErrors", "noDiagnostics",
26
+ "events", "noEvents", "probes",
27
+ "nodes", "nodesAbsent", "attrs", "data", "flags", "slots", "rows",
28
+ "geometry", "relations", "degraded", "snapshotAvailable", "note",
29
+ }
30
+ OBSERVE_KEYS = {"events", "probes", "nodes", "attrs", "data", "flags",
31
+ "slots", "rows", "geometry"}
32
+ CASE_KEYS = {
33
+ "id", "title", "spec", "program", "steps", "requires",
34
+ "loadExpect", "capabilities", "limits", "seedState", "renderGeometry",
35
+ "rendering", "capabilityModules",
36
+ }
37
+ STEP_KEYS = {"send", "fire", "interact", "restart", "snapshot", "expect"}
38
+ STEP_ACTIONS = {"send", "fire", "interact", "restart", "snapshot", "expect"}
39
+ BEHAVIORS = {"return", "error", "hang", "nonjson", "delay"}
40
+ LEVELS = {"error", "warning", "info"}
41
+ # `interact` 的动作集 = 规范 05 第 4 节的交互事件名
42
+ INTERACTIONS = {"click", "change", "submit", "focus", "blur"}
43
+
44
+ # 几何关系词汇(见 spec/05-render-contract.md 第 8 节)。全部为**定性/相对**断言:
45
+ # 不比较绝对坐标,因而跨渲染器(字号 / DPI 不同)可比。
46
+ RELATIONS = {
47
+ "left_of", "right_of", "above", "below",
48
+ "contains", "inside", "overlaps", "disjoint",
49
+ "aligned_x", "aligned_y", "aligned_center_x", "aligned_center_y",
50
+ "same_width", "same_height", "same_size",
51
+ "gap_h", "gap_v", "width_ratio", "height_ratio",
52
+ }
53
+ RELATION_NEEDS_VALUE = {"gap_h", "gap_v", "width_ratio", "height_ratio"}
54
+ # 观测/驱动能力(布尔):geometry / snapshot / interaction / headless
55
+ DEGRADED_OBS = {"geometry", "snapshot", "interaction", "headless"}
56
+ # 词汇能力(列表):control:<类型> / attr:<名> / animation:<名> / icon:<名>
57
+ DEGRADED_PREFIXES = ("control:", "attr:", "animation:", "icon:")
58
+ DEFAULT_TOL = 1.0
59
+
60
+
61
+ # ---------------------------------------------------------------- 用例装载
62
+
63
+ def load_cases(cases_dir):
64
+ """返回 (cases, problems)。cases 为 (来源文件, 用例) 列表。"""
65
+ problems = []
66
+ cases = []
67
+ if not os.path.isdir(cases_dir):
68
+ return cases, ["用例目录不存在:%s" % cases_dir]
69
+ seen = {}
70
+ for name in sorted(os.listdir(cases_dir)):
71
+ if not name.endswith(".json"):
72
+ continue
73
+ path = os.path.join(cases_dir, name)
74
+ try:
75
+ with open(path, "r", encoding="utf-8") as fh:
76
+ data = json.load(fh)
77
+ except Exception as ex: # noqa: BLE001 - 报告而非抛出
78
+ problems.append("%s: 不是合法 JSON:%s" % (name, ex))
79
+ continue
80
+ if not isinstance(data, list):
81
+ problems.append("%s: 顶层必须是用例数组" % name)
82
+ continue
83
+ for i, case in enumerate(data):
84
+ where = "%s[%d]" % (name, i)
85
+ problems += validate_case(case, where)
86
+ if isinstance(case, dict) and isinstance(case.get("id"), str):
87
+ cid = case["id"]
88
+ if cid in seen:
89
+ problems.append("%s: id 重复(已在 %s 出现)" % (where, seen[cid]))
90
+ seen[cid] = where
91
+ cases.append((name, case))
92
+ return cases, problems
93
+
94
+
95
+ def _req(problems, case, key, types, where):
96
+ if key not in case:
97
+ problems.append("%s: 缺少必需字段 %s" % (where, key))
98
+ return False
99
+ if not isinstance(case[key], types):
100
+ problems.append("%s: 字段 %s 类型不符" % (where, key))
101
+ return False
102
+ return True
103
+
104
+
105
+ def validate_expect(expect, where, problems):
106
+ if not isinstance(expect, dict):
107
+ problems.append("%s: 期望必须是对象" % where)
108
+ return
109
+ for key in expect:
110
+ if key not in EXPECT_KEYS:
111
+ problems.append("%s: 未知期望键 %s" % (where, key))
112
+ for d in expect.get("diagnostics", []):
113
+ if not isinstance(d, dict) or "code" not in d:
114
+ problems.append("%s: diagnostics 的每项必须含 code" % where)
115
+ elif "level" in d and d["level"] not in LEVELS:
116
+ problems.append("%s: 诊断级别非法:%r" % (where, d["level"]))
117
+ for code in expect.get("forbid", []):
118
+ if not isinstance(code, str):
119
+ problems.append("%s: forbid 的每项必须是字符串" % where)
120
+ for ev in expect.get("events", []):
121
+ if not isinstance(ev, dict) or "target" not in ev or "event" not in ev:
122
+ problems.append("%s: events 的每项必须含 target 与 event" % where)
123
+ if "noEvents" in expect and not isinstance(expect["noEvents"], bool):
124
+ problems.append("%s: noEvents 必须是布尔" % where)
125
+ for pr in expect.get("probes", []):
126
+ if not isinstance(pr, dict) or "verb" not in pr:
127
+ problems.append("%s: probes 的每项必须含 verb" % where)
128
+ for key in ("nodes", "nodesAbsent"):
129
+ for nid in expect.get(key, []):
130
+ if not isinstance(nid, str) or not nid.startswith("#"):
131
+ problems.append("%s: %s 的每项必须是 # 开头的地址" % (where, key))
132
+ for key in ("attrs", "data", "flags", "slots", "rows", "geometry"):
133
+ if key in expect and not isinstance(expect[key], dict):
134
+ problems.append("%s: %s 必须是对象" % (where, key))
135
+ for rel in expect.get("relations", []):
136
+ if not isinstance(rel, dict):
137
+ problems.append("%s: relations 的每项必须是对象" % where)
138
+ continue
139
+ name = rel.get("rel")
140
+ if name not in RELATIONS:
141
+ problems.append("%s: 未知关系 %r" % (where, name))
142
+ for side in ("a", "b"):
143
+ val = rel.get(side)
144
+ if not isinstance(val, str) or not val.startswith("#"):
145
+ problems.append("%s: relations.%s 必须是 # 开头的地址" % (where, side))
146
+ if "tol" in rel and not isinstance(rel["tol"], (int, float)):
147
+ problems.append("%s: relations.tol 必须是数字" % where)
148
+ if name in RELATION_NEEDS_VALUE and "value" not in rel:
149
+ problems.append("%s: 关系 %s 需要 value" % (where, name))
150
+ if "value" in rel and not isinstance(rel["value"], (int, float)):
151
+ problems.append("%s: relations.value 必须是数字" % where)
152
+ for feat in expect.get("degraded", []):
153
+ if not isinstance(feat, str):
154
+ problems.append("%s: degraded 的每项必须是字符串" % where)
155
+ elif not (feat in DEGRADED_OBS or feat.startswith(DEGRADED_PREFIXES)):
156
+ problems.append("%s: degraded 特性 %r 形式非法" % (where, feat))
157
+ if "snapshotAvailable" in expect and not isinstance(expect["snapshotAvailable"], bool):
158
+ problems.append("%s: snapshotAvailable 必须是布尔" % where)
159
+ if "minErrors" in expect and not isinstance(expect["minErrors"], int):
160
+ problems.append("%s: minErrors 必须是整数" % where)
161
+
162
+
163
+ def validate_case(case, where):
164
+ problems = []
165
+ if not isinstance(case, dict):
166
+ return ["%s: 用例必须是对象" % where]
167
+ for key in case:
168
+ if key not in CASE_KEYS:
169
+ problems.append("%s: 未知字段 %s" % (where, key))
170
+ for key in ("id", "title", "spec"):
171
+ _req(problems, case, key, str, where)
172
+ if _req(problems, case, "program", list, where):
173
+ for line in case["program"]:
174
+ if not isinstance(line, str):
175
+ problems.append("%s: program 的每行必须是字符串" % where)
176
+ if "loadExpect" in case:
177
+ validate_expect(case["loadExpect"], where + ".loadExpect", problems)
178
+ if _req(problems, case, "steps", list, where):
179
+ if not case["steps"]:
180
+ problems.append("%s: steps 不能为空" % where)
181
+ for i, step in enumerate(case["steps"]):
182
+ sw = "%s.steps[%d]" % (where, i)
183
+ if not isinstance(step, dict):
184
+ problems.append("%s: 步骤必须是对象" % sw)
185
+ continue
186
+ for key in step:
187
+ if key not in STEP_KEYS:
188
+ problems.append("%s: 未知字段 %s" % (sw, key))
189
+ if not (STEP_ACTIONS & set(step)):
190
+ problems.append("%s: 必须至少含 send / fire / restart / expect 之一" % sw)
191
+ if "send" in step:
192
+ if not isinstance(step["send"], list) or not all(
193
+ isinstance(x, str) for x in step["send"]):
194
+ problems.append("%s: send 必须是字符串数组" % sw)
195
+ if "fire" in step:
196
+ fire = step["fire"]
197
+ if not isinstance(fire, dict) or "target" not in fire or "event" not in fire:
198
+ problems.append("%s: fire 必须含 target 与 event" % sw)
199
+ if "restart" in step and not isinstance(step["restart"], bool):
200
+ problems.append("%s: restart 必须是布尔" % sw)
201
+ if "interact" in step:
202
+ act = step["interact"]
203
+ if not isinstance(act, dict) or "target" not in act or "action" not in act:
204
+ problems.append("%s: interact 必须含 target 与 action" % sw)
205
+ elif act["action"] not in INTERACTIONS:
206
+ problems.append("%s: interact.action 非法:%r" % (sw, act["action"]))
207
+ if "snapshot" in step and not isinstance(step["snapshot"], bool):
208
+ problems.append("%s: snapshot 必须是布尔" % sw)
209
+ if "expect" in step:
210
+ validate_expect(step["expect"], sw + ".expect", problems)
211
+ for cap in case.get("capabilities", []):
212
+ if not isinstance(cap, dict) or "name" not in cap:
213
+ problems.append("%s: capabilities 的每项必须含 name" % where)
214
+ elif cap.get("behavior", "return") not in BEHAVIORS:
215
+ problems.append("%s: capability behavior 非法:%r" % (where, cap.get("behavior")))
216
+ if "limits" in case and not isinstance(case["limits"], dict):
217
+ problems.append("%s: limits 必须是对象" % where)
218
+ if "seedState" in case and not isinstance(case["seedState"], dict):
219
+ problems.append("%s: seedState 必须是对象" % where)
220
+ for req in case.get("requires", []):
221
+ if not isinstance(req, str):
222
+ problems.append("%s: requires 的每项必须是字符串(能力名)" % where)
223
+ for path in case.get("capabilityModules", []):
224
+ if not isinstance(path, str):
225
+ problems.append("%s: capabilityModules 的每项必须是字符串路径" % where)
226
+ if "rendering" in case:
227
+ r = case["rendering"]
228
+ if not isinstance(r, dict):
229
+ problems.append("%s: rendering 必须是对象(能力声明替身)" % where)
230
+ else:
231
+ for key in ("controls", "attributes", "animations", "icons"):
232
+ if key in r and r[key] is not None and not isinstance(r[key], list):
233
+ problems.append("%s: rendering.%s 必须是数组或 null" % (where, key))
234
+ for key in ("geometry", "snapshot", "headless"):
235
+ if key in r and not isinstance(r[key], bool):
236
+ problems.append("%s: rendering.%s 必须是布尔" % (where, key))
237
+ if "renderGeometry" in case:
238
+ rg = case["renderGeometry"]
239
+ if not isinstance(rg, dict):
240
+ problems.append("%s: renderGeometry 必须是对象" % where)
241
+ else:
242
+ for addr, rect in rg.items():
243
+ if not isinstance(addr, str) or not addr.startswith("#"):
244
+ problems.append("%s: renderGeometry 的键必须是 # 开头的地址" % where)
245
+ elif not isinstance(rect, dict) or \
246
+ not {"x", "y", "width", "height"} <= set(rect):
247
+ problems.append("%s: renderGeometry[%s] 必须含 x/y/width/height"
248
+ % (where, addr))
249
+ return problems
250
+
251
+
252
+ # ---------------------------------------------------------------- 期望匹配
253
+
254
+ def deep_subset(expected, actual, path, problems):
255
+ if isinstance(expected, dict):
256
+ if not isinstance(actual, dict):
257
+ problems.append("%s: 期望对象,实际 %r" % (path, actual))
258
+ return
259
+ for key, val in expected.items():
260
+ if key not in actual:
261
+ problems.append("%s.%s: 实际结果中缺失" % (path, key))
262
+ else:
263
+ deep_subset(val, actual[key], "%s.%s" % (path, key), problems)
264
+ return
265
+ if isinstance(expected, list):
266
+ if not isinstance(actual, list) or len(expected) != len(actual):
267
+ problems.append("%s: 期望 %d 项列表,实际 %r" % (path, len(expected), actual))
268
+ return
269
+ for i, (e, a) in enumerate(zip(expected, actual)):
270
+ deep_subset(e, a, "%s[%d]" % (path, i), problems)
271
+ return
272
+ if expected != actual:
273
+ problems.append("%s: 期望 %r,实际 %r" % (path, expected, actual))
274
+
275
+
276
+ def _any_subset(want, candidates):
277
+ """候选中是否存在一个,使得 `want` 是它的(递归)子集。"""
278
+ for cand in candidates:
279
+ if not isinstance(cand, dict):
280
+ continue
281
+ probs = []
282
+ deep_subset(want, cand, "", probs)
283
+ if not probs:
284
+ return True
285
+ return False
286
+
287
+
288
+ # ---------------------------------------------------------------- 几何关系与降级
289
+
290
+ def _rect_edges(rect):
291
+ x = float(rect["x"])
292
+ y = float(rect["y"])
293
+ return x, y, x + float(rect["width"]), y + float(rect["height"])
294
+
295
+
296
+ def eval_relation(spec, rects):
297
+ """评估一条关系断言,返回不成立的原因(空列表 = 成立)。"""
298
+ name = spec.get("rel")
299
+ addr_a, addr_b = spec.get("a"), spec.get("b")
300
+ ra, rb = rects.get(addr_a), rects.get(addr_b)
301
+ if ra is None or rb is None:
302
+ return ["节点 %s 没有几何" % (addr_a if ra is None else addr_b)]
303
+ default_tol = 0.05 if name in ("width_ratio", "height_ratio") else DEFAULT_TOL
304
+ tol = float(spec.get("tol", default_tol))
305
+ ax0, ay0, ax1, ay1 = _rect_edges(ra)
306
+ bx0, by0, bx1, by1 = _rect_edges(rb)
307
+ problems = []
308
+
309
+ def fail(desc, want=None, got=None):
310
+ extra = "" if want is None else "(期望 %s,实际 %s)" % (want, got)
311
+ problems.append("%s %s vs %s%s" % (desc, addr_a, addr_b, extra))
312
+
313
+ if name == "left_of":
314
+ if not ax1 <= bx0 + tol:
315
+ fail("不在左侧")
316
+ elif name == "right_of":
317
+ if not ax0 >= bx1 - tol:
318
+ fail("不在右侧")
319
+ elif name == "above":
320
+ if not ay1 <= by0 + tol:
321
+ fail("不在上方")
322
+ elif name == "below":
323
+ if not ay0 >= by1 - tol:
324
+ fail("不在下方")
325
+ elif name == "contains":
326
+ if not (ax0 <= bx0 + tol and ay0 <= by0 + tol
327
+ and ax1 + tol >= bx1 and ay1 + tol >= by1):
328
+ fail("不包含")
329
+ elif name == "inside":
330
+ if not (bx0 <= ax0 + tol and by0 <= ay0 + tol
331
+ and bx1 + tol >= ax1 and by1 + tol >= ay1):
332
+ fail("不在其内")
333
+ elif name in ("overlaps", "disjoint"):
334
+ intersects = (ax0 < bx1 - tol and bx0 < ax1 - tol
335
+ and ay0 < by1 - tol and by0 < ay1 - tol)
336
+ if name == "overlaps" and not intersects:
337
+ fail("不重叠")
338
+ if name == "disjoint" and intersects:
339
+ fail("重叠了")
340
+ elif name == "aligned_x":
341
+ if abs(ax0 - bx0) > tol:
342
+ fail("左缘未对齐", bx0, ax0)
343
+ elif name == "aligned_y":
344
+ if abs(ay0 - by0) > tol:
345
+ fail("上缘未对齐", by0, ay0)
346
+ elif name == "aligned_center_x":
347
+ if abs((ax0 + ax1) - (bx0 + bx1)) > 2 * tol:
348
+ fail("水平未居中")
349
+ elif name == "aligned_center_y":
350
+ if abs((ay0 + ay1) - (by0 + by1)) > 2 * tol:
351
+ fail("垂直未居中")
352
+ elif name == "same_width":
353
+ if abs((ax1 - ax0) - (bx1 - bx0)) > tol:
354
+ fail("宽度不等", bx1 - bx0, ax1 - ax0)
355
+ elif name == "same_height":
356
+ if abs((ay1 - ay0) - (by1 - by0)) > tol:
357
+ fail("高度不等", by1 - by0, ay1 - ay0)
358
+ elif name == "same_size":
359
+ if abs((ax1 - ax0) - (bx1 - bx0)) > tol or abs((ay1 - ay0) - (by1 - by0)) > tol:
360
+ fail("尺寸不等")
361
+ elif name == "gap_h":
362
+ want = float(spec["value"])
363
+ got = bx0 - ax1
364
+ if abs(got - want) > tol:
365
+ fail("水平间隙不符", want, got)
366
+ elif name == "gap_v":
367
+ want = float(spec["value"])
368
+ got = by0 - ay1
369
+ if abs(got - want) > tol:
370
+ fail("垂直间隙不符", want, got)
371
+ elif name in ("width_ratio", "height_ratio"):
372
+ want = float(spec["value"])
373
+ if name == "width_ratio":
374
+ got = (ax1 - ax0) / (bx1 - bx0) if (bx1 - bx0) else 0.0
375
+ else:
376
+ got = (ay1 - ay0) / (by1 - by0) if (by1 - by0) else 0.0
377
+ if abs(got - want) > tol:
378
+ fail("比例不符", want, round(got, 3))
379
+ return problems
380
+
381
+
382
+ def _declared(rendering, feature):
383
+ """该特性是否被实现"声明支持"。
384
+
385
+ 列表为 `null` 或缺省 = 支持全部标准(这是给"懒得枚举"的实现的便利);
386
+ 显式数组 = 只支持所列子集。布尔观测能力直接读同名键。
387
+ """
388
+ rendering = rendering or {}
389
+ if feature in DEGRADED_OBS:
390
+ return bool(rendering.get(feature))
391
+ for prefix, key in (("control:", "controls"), ("attr:", "attributes"),
392
+ ("animation:", "animations"), ("icon:", "icons")):
393
+ if feature.startswith(prefix):
394
+ listed = rendering.get(key)
395
+ return listed is None or feature[len(prefix):] in listed
396
+ return False
397
+
398
+
399
+ def _has_degraded(diags, feature):
400
+ for d in diags:
401
+ if (isinstance(d, dict) and d.get("code") == "DEGRADED_FEATURE"
402
+ and d.get("feature") == feature):
403
+ return True
404
+ return False
405
+
406
+
407
+ def _is_expected_degradation(diag, rendering):
408
+ """未声明支持的特性产生的 DEGRADED_FEATURE 是**规范要求的行为**,不是缺陷。
409
+
410
+ `noDiagnostics` 因此对它豁免——否则任何一个子集渲染器都过不了写死
411
+ "零诊断"的用例,套件就不再渲染器无关(声明即 oracle 的另一半)。
412
+ """
413
+ if not isinstance(diag, dict) or diag.get("code") != "DEGRADED_FEATURE":
414
+ return False
415
+ feature = diag.get("feature")
416
+ return bool(feature) and not _declared(rendering, feature)
417
+
418
+
419
+ def check_expect(expect, diags, snap, where, rendering=None, geometry_available=False):
420
+ problems = []
421
+ by_code = {}
422
+ for d in diags:
423
+ if isinstance(d, dict) and isinstance(d.get("code"), str):
424
+ by_code.setdefault(d["code"], []).append(d)
425
+
426
+ for want in expect.get("diagnostics", []):
427
+ matched = False
428
+ for have in by_code.get(want["code"], []):
429
+ if all(have.get(k) == v for k, v in want.items()):
430
+ matched = True
431
+ break
432
+ if not matched:
433
+ problems.append("%s: 缺少诊断 %s" % (where, json.dumps(want, ensure_ascii=False)))
434
+ for code in expect.get("forbid", []):
435
+ if code in by_code:
436
+ problems.append("%s: 出现了不应出现的诊断 %s" % (where, code))
437
+ if "minErrors" in expect:
438
+ n = sum(1 for d in diags if isinstance(d, dict) and d.get("level") == "error")
439
+ if n < expect["minErrors"]:
440
+ problems.append("%s: 需要至少 %d 条错误级诊断,实际 %d"
441
+ % (where, expect["minErrors"], n))
442
+ if expect.get("noDiagnostics"):
443
+ silent = [d for d in diags if not _is_expected_degradation(d, rendering)]
444
+ if silent:
445
+ problems.append("%s: 期望无诊断,实际 %s"
446
+ % (where, json.dumps(silent, ensure_ascii=False)))
447
+
448
+ events = (snap.get("events") or []) if isinstance(snap, dict) else []
449
+ for want in expect.get("events", []):
450
+ if not _any_subset(want, events):
451
+ problems.append("%s: 缺少事件 %s" % (where, json.dumps(want, ensure_ascii=False)))
452
+ if expect.get("noEvents") and events:
453
+ problems.append("%s: 期望无订阅事件,实际 %s"
454
+ % (where, json.dumps(events, ensure_ascii=False)))
455
+
456
+ probes = (snap.get("probes") or []) if isinstance(snap, dict) else []
457
+ for want in expect.get("probes", []):
458
+ if not _any_subset(want, probes):
459
+ problems.append("%s: 缺少探针结果 %s"
460
+ % (where, json.dumps(want, ensure_ascii=False)))
461
+
462
+ nodes = snap.get("nodes", []) if isinstance(snap, dict) else []
463
+ for nid in expect.get("nodes", []):
464
+ if nid not in nodes:
465
+ problems.append("%s: 节点 %s 不存在" % (where, nid))
466
+ for nid in expect.get("nodesAbsent", []):
467
+ if nid in nodes:
468
+ problems.append("%s: 节点 %s 不应存在" % (where, nid))
469
+ for key in ("attrs", "data", "flags", "slots", "rows"):
470
+ if key in expect:
471
+ deep_subset(expect[key], (snap or {}).get(key, {}), "%s.%s" % (where, key), problems)
472
+ if "geometry" in expect and geometry_available:
473
+ deep_subset(expect["geometry"], (snap or {}).get("geometry", {}) or {},
474
+ "%s.geometry" % where, problems)
475
+ # 关系断言:几何可用 → 硬断言;不可用 → 只要求**可见降级**(静默才是失败)
476
+ for spec in expect.get("relations", []):
477
+ if geometry_available:
478
+ rects = (snap or {}).get("geometry") or {}
479
+ for detail in eval_relation(spec, rects):
480
+ problems.append("%s: 关系 %s 不成立:%s"
481
+ % (where, spec.get("rel"), detail))
482
+ elif not _has_degraded(diags, "geometry"):
483
+ problems.append("%s: 实现未声明几何,却未产生 DEGRADED_FEATURE(feature=geometry)"
484
+ "——几何被静默省略" % where)
485
+ # 声明即 oracle:未声明支持的特性必须出现可见降级;声明支持的则不要求降级
486
+ # (谎报"支持"会被行为用例抓出来)。
487
+ for feat in expect.get("degraded", []):
488
+ if _declared(rendering, feat):
489
+ continue
490
+ if not _has_degraded(diags, feat):
491
+ problems.append("%s: 未声明支持 %s,但未见 DEGRADED_FEATURE(feature=%s)"
492
+ % (where, feat, feat))
493
+ if "snapshotAvailable" in expect:
494
+ # 只断言"真的产出了图像"(非空),**不做像素比对**——像素级外观属实现自由
495
+ got = bool((snap or {}).get("snapshotAvailable"))
496
+ if got != bool(expect["snapshotAvailable"]):
497
+ problems.append("%s: 截图可用性期望 %r,实际 %r"
498
+ % (where, expect["snapshotAvailable"], got))
499
+ return problems
500
+
501
+
502
+ # ---------------------------------------------------------------- 实现进程
503
+
504
+ class Impl:
505
+ def __init__(self, command, cwd=None):
506
+ self.command = command
507
+ self.cwd = cwd
508
+ self.proc = None
509
+ self.rendering = {}
510
+ self.spawn()
511
+ self.declare()
512
+
513
+ def spawn(self):
514
+ self.proc = subprocess.Popen( # noqa: S602 - 命令由使用者提供
515
+ self.command, shell=True, cwd=self.cwd,
516
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
517
+ text=True, encoding="utf-8",
518
+ )
519
+
520
+ def declare(self):
521
+ """启动握手:读取实现声明的渲染能力——"声明即 oracle"的输入。
522
+
523
+ 未实现 `hello` 的旧实现会回一条 error;此时视为"未声明",不影响非渲染
524
+ 用例;渲染用例会要求它对被请求的一切产生可见降级。
525
+ """
526
+ self.rendering = {}
527
+ try:
528
+ resp = self.request({"op": "hello"})
529
+ except Exception: # noqa: BLE001 - 握手失败不致命,视为未声明
530
+ return self.rendering
531
+ if isinstance(resp, dict):
532
+ self.rendering = resp.get("rendering") or {}
533
+ return self.rendering
534
+
535
+ def request(self, payload):
536
+ assert self.proc and self.proc.stdin and self.proc.stdout
537
+ self.proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
538
+ self.proc.stdin.flush()
539
+ line = self.proc.stdout.readline()
540
+ if not line:
541
+ raise RuntimeError("实现进程未响应即退出(stderr: %s)" % self._stderr())
542
+ return json.loads(line)
543
+
544
+ def _stderr(self):
545
+ try:
546
+ self.proc.terminate()
547
+ _, err = self.proc.communicate(timeout=2)
548
+ return (err or "").strip()[:400]
549
+ except Exception: # noqa: BLE001
550
+ return ""
551
+
552
+ def kill(self):
553
+ try:
554
+ if self.proc:
555
+ self.proc.kill()
556
+ self.proc.wait(timeout=2)
557
+ except Exception: # noqa: BLE001
558
+ pass
559
+
560
+ def restart(self):
561
+ """一条用例把实现搞崩之后,重启它,避免污染后续用例。"""
562
+ self.kill()
563
+ self.spawn()
564
+ self.declare()
565
+
566
+ def close(self):
567
+ try:
568
+ self.request({"op": "quit"})
569
+ except Exception: # noqa: BLE001 - 退出协议失败不致命
570
+ pass
571
+ finally:
572
+ self.kill()
573
+
574
+
575
+ def run_case(case, impl, wait_seconds):
576
+ """返回不匹配项列表。每步的期望只针对该步产生的诊断。
577
+
578
+ **声明即 oracle**:由启动握手(或因用例自带 `renderGeometry` 测试替身)得知
579
+ 几何是否可用,据此决定几何关系断言是**硬断言**,还是退化为**要求可见降级**。
580
+ """
581
+ problems = []
582
+ # 能力声明:用例可用 `rendering` 替身覆盖实现的真实声明(声明即 oracle)。
583
+ rendering = case.get("rendering") or impl.rendering or {}
584
+ geometry_available = bool(rendering.get("geometry")) or bool(case.get("renderGeometry"))
585
+
586
+ req = {"op": "load", "program": case["program"]}
587
+ for key in ("capabilities", "limits", "seedState", "renderGeometry", "rendering"):
588
+ if key in case:
589
+ req[key] = case[key]
590
+ if case.get("capabilityModules"):
591
+ # 相对路径按 conformance/ 解析(用例不写本机绝对路径)
592
+ req["capabilityModules"] = [
593
+ p if os.path.isabs(p) else os.path.join(HERE, p)
594
+ for p in case["capabilityModules"]]
595
+ resp = impl.request(req)
596
+ load_diags = list(resp.get("diagnostics", []))
597
+ # 只有用例真的要断言装载期诊断时才观察一次:**没人监听时不消费观察流**,
598
+ # 否则装载后立刻完成的异步诊断会被吸进装载期,步骤断言就看不到它了。
599
+ if "loadExpect" in case:
600
+ snap = impl.request({"op": "observe"})
601
+ load_diags += snap.get("diagnostics", [])
602
+ problems += check_expect(case["loadExpect"], load_diags, snap, "load",
603
+ rendering, geometry_available)
604
+
605
+ for i, step in enumerate(case["steps"], 1):
606
+ where = "step %d" % i
607
+ diags = []
608
+ if "send" in step:
609
+ diags += impl.request({"op": "send", "batch": step["send"]}).get("diagnostics", [])
610
+ if "fire" in step:
611
+ req = {"op": "fire"}
612
+ req.update(step["fire"])
613
+ diags += impl.request(req).get("diagnostics", [])
614
+ if "interact" in step:
615
+ req = {"op": "interact"}
616
+ req.update(step["interact"])
617
+ diags += impl.request(req).get("diagnostics", [])
618
+ if step.get("restart"):
619
+ diags = impl.request({"op": "restart"}).get("diagnostics", [])
620
+ snapshot_image = None
621
+ if step.get("snapshot"):
622
+ snap_resp = impl.request({"op": "snapshot"})
623
+ diags += snap_resp.get("diagnostics", [])
624
+ # 只记"是否真的产出了图像",不比对像素
625
+ snapshot_image = bool(snap_resp.get("image"))
626
+ want = step.get("expect")
627
+ if want is None:
628
+ continue
629
+ # 几何不可用但用例断言了关系时,驱动者先"问一次"几何(where 探针),
630
+ # 给实现一个报出可见降级的机会——静默才是失败。
631
+ rels = want.get("relations") or []
632
+ if rels and not geometry_available:
633
+ first = (rels[0].get("a") or "").lstrip("#")
634
+ diags += impl.request({"op": "send", "batch": ["where #%s" % first]}) \
635
+ .get("diagnostics", [])
636
+ deadline = time.time() + wait_seconds
637
+ last = None
638
+ seen_events = []
639
+ seen_probes = []
640
+ while True:
641
+ snap = impl.request({"op": "observe"})
642
+ diags += snap.get("diagnostics", [])
643
+ # 事件与诊断一样是"自上次请求以来"的增量;轮询时累积,否则会被后一次观察清空
644
+ seen_events += snap.get("events") or []
645
+ seen_probes += snap.get("probes") or []
646
+ view = dict(snap)
647
+ view["events"] = seen_events
648
+ view["probes"] = seen_probes
649
+ view["snapshotAvailable"] = snapshot_image
650
+ last = check_expect(want, diags, view, where, rendering, geometry_available)
651
+ if not last or time.time() >= deadline:
652
+ break
653
+ time.sleep(0.05)
654
+ problems += last
655
+ return problems
656
+
657
+
658
+ # ---------------------------------------------------------------- 入口
659
+
660
+ def main(argv=None):
661
+ ap = argparse.ArgumentParser(description="Puppet 语言标准 conformance 运行器")
662
+ ap.add_argument("--check", action="store_true", help="只校验用例文件,不需要实现")
663
+ ap.add_argument("--impl", help="启动实现进程的命令")
664
+ ap.add_argument("--impl-module", help="以 'python -m <模块>' 方式启动实现(无需处理引号)")
665
+ ap.add_argument("--cases", default=DEFAULT_CASES, help="用例目录")
666
+ ap.add_argument("--filter", default="", help="只跑 id 含该子串的用例")
667
+ ap.add_argument("--wait", type=float, default=3.0, help="每步观察轮询上限(秒)")
668
+ args = ap.parse_args(argv)
669
+
670
+ cases, problems = load_cases(args.cases)
671
+ if problems:
672
+ print("用例文件有问题:")
673
+ for p in problems:
674
+ print(" - " + p)
675
+ return 1
676
+ if args.check:
677
+ print("用例文件校验通过:%d 条用例,来自 %s"
678
+ % (len(cases), os.path.relpath(args.cases, HERE)))
679
+ return 0
680
+
681
+ if args.impl_module:
682
+ args.impl = '"%s" -m %s' % (sys.executable, args.impl_module)
683
+ if not args.impl:
684
+ print("需要 --check / --impl / --impl-module 之一。", file=sys.stderr)
685
+ return 2
686
+
687
+ selected = [(n, c) for n, c in cases if args.filter in c["id"]]
688
+ impl = Impl(args.impl, cwd=os.path.dirname(HERE))
689
+ failed = 0
690
+ skipped = 0
691
+ try:
692
+ for name, case in selected:
693
+ # 能力门槛:实现未声明的能力 → **可见跳过**(不是静默通过)
694
+ effective = case.get("rendering") or impl.rendering or {}
695
+ missing = [c for c in case.get("requires", []) if not _declared(effective, c)]
696
+ if missing:
697
+ skipped += 1
698
+ print("skip %s (实现未声明:%s)" % (case["id"], ", ".join(missing)))
699
+ continue
700
+ try:
701
+ issues = run_case(case, impl, args.wait)
702
+ except Exception as ex: # noqa: BLE001 - 单条用例失败不终止整轮
703
+ issues = ["运行异常:%s" % ex]
704
+ impl.restart()
705
+ if issues:
706
+ failed += 1
707
+ print("FAIL %s (%s)" % (case["id"], case["title"]))
708
+ for it in issues:
709
+ print(" " + it)
710
+ else:
711
+ print("ok %s" % case["id"])
712
+ finally:
713
+ impl.close()
714
+
715
+ print("\n%d/%d 通过(跳过 %d)"
716
+ % (len(selected) - failed - skipped, len(selected), skipped))
717
+ return 1 if failed else 0
718
+
719
+
720
+ if __name__ == "__main__":
721
+ raise SystemExit(main())