@guanlin4924/devflow-pi 0.1.96 → 0.1.97
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.
- package/README.md +11 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +678 -91
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/startup-briefing.ts +109 -0
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import base64
|
|
7
|
+
from collections import deque
|
|
7
8
|
import hashlib
|
|
8
9
|
import html
|
|
9
10
|
import json
|
|
@@ -41,80 +42,144 @@ def mermaid_label(value: Any) -> str:
|
|
|
41
42
|
.replace("\r", " ").replace("\n", " "))
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def render_screen(screen: dict[str, Any], platform: str) -> str:
|
|
45
|
+
def render_screen(screen: dict[str, Any], platform: str, state: str = "default") -> str:
|
|
45
46
|
mobile = platform == "mobile"
|
|
46
|
-
width,
|
|
47
|
+
width, default_height = (390, 760) if mobile else (960, 600)
|
|
47
48
|
pad = 20 if mobile else 48
|
|
48
|
-
pieces
|
|
49
|
+
pieces: list[str] = []
|
|
49
50
|
y = 45 if mobile else 58
|
|
50
|
-
|
|
51
|
+
presentation = screen.get("presentation")
|
|
52
|
+
overlay = presentation in {"modal", "drawer"}
|
|
53
|
+
if overlay:
|
|
51
54
|
if mobile:
|
|
52
|
-
panel_x, panel_y, panel_w,
|
|
55
|
+
panel_x, panel_y, panel_w, original_panel_h = 0, 220, width, default_height - 220
|
|
53
56
|
pad, y = 24, 265
|
|
54
|
-
elif
|
|
55
|
-
panel_x, panel_y, panel_w,
|
|
57
|
+
elif presentation == "drawer":
|
|
58
|
+
panel_x, panel_y, panel_w, original_panel_h = 540, 0, 420, default_height
|
|
56
59
|
pad, y = 570, 48
|
|
57
60
|
else:
|
|
58
|
-
panel_x, panel_y, panel_w,
|
|
61
|
+
panel_x, panel_y, panel_w, original_panel_h = 270, 70, 420, 460
|
|
59
62
|
pad, y = 300, 115
|
|
60
|
-
pieces.append(f'<rect width="{width}" height="{height}" fill="#0f172a33"/>')
|
|
61
|
-
pieces.append(f'<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" rx="12" fill="white" stroke="#7c3aed" stroke-width="2"/>')
|
|
62
63
|
content_right = panel_x + panel_w - (24 if mobile else 30)
|
|
63
64
|
else:
|
|
64
65
|
content_right = width - pad
|
|
65
66
|
content_width = content_right - pad
|
|
66
|
-
|
|
67
|
+
elements = screen.get("elements", [])
|
|
68
|
+
if overlay and not any(element.get("type") == "page_header" for element in elements):
|
|
69
|
+
pieces.append(svg_text(pad, y, screen.get("title"), 18, weight="700"))
|
|
70
|
+
y += 40
|
|
71
|
+
|
|
72
|
+
data_types = {"table", "card_list"}
|
|
73
|
+
has_data_region = any(element.get("type") in data_types for element in elements)
|
|
74
|
+
error_drawn = False
|
|
75
|
+
|
|
76
|
+
def error_bar() -> None:
|
|
77
|
+
nonlocal y, error_drawn
|
|
78
|
+
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="40" fill="#fef2f2" stroke="#fca5a5"/>')
|
|
79
|
+
pieces.append(svg_text(pad + 16, y + 26, "加载失败,请重试", 14, "#b91c1c"))
|
|
80
|
+
y += 62
|
|
81
|
+
error_drawn = True
|
|
82
|
+
|
|
83
|
+
if state == "error" and not has_data_region and not elements:
|
|
84
|
+
error_bar()
|
|
85
|
+
|
|
86
|
+
bottom_navigation: dict[str, Any] | None = None
|
|
87
|
+
for element_index, element in enumerate(elements):
|
|
67
88
|
typ = element.get("type")
|
|
89
|
+
if state == "error" and has_data_region and typ in data_types and not error_drawn:
|
|
90
|
+
error_bar()
|
|
68
91
|
if typ == "page_header":
|
|
69
92
|
pieces.append(svg_text(pad, y, element.get("title", screen.get("title")), 21 if mobile else 25, weight="700"))
|
|
70
93
|
y += 48
|
|
71
94
|
elif typ == "filter_bar":
|
|
72
|
-
box_h = 92 if mobile else 76
|
|
73
|
-
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
74
95
|
fields = element.get("fields", [])
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
per_row = 1 if mobile else max(1, (content_width - 24) // 220)
|
|
97
|
+
rows = (len(fields) + per_row - 1) // per_row
|
|
98
|
+
box_h = 20 + rows * 56 if fields else 76
|
|
99
|
+
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
100
|
+
for i, field in enumerate(fields):
|
|
101
|
+
row, col = divmod(i, per_row)
|
|
102
|
+
x = pad + 16 if mobile else pad + 24 + col * 220
|
|
103
|
+
field_y = y + row * 56
|
|
104
|
+
pieces.append(svg_text(x, field_y + 28, field.get("label"), 12, "#64748b"))
|
|
105
|
+
pieces.append(f'<rect x="{x}" y="{field_y + 38}" width="{150 if mobile else 180}" height="24" fill="#f1f5f9" stroke="#cbd5e1"/>')
|
|
79
106
|
y += box_h + 22
|
|
80
107
|
elif typ == "table":
|
|
81
108
|
box_h = 290 if mobile else 300
|
|
82
109
|
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
83
110
|
columns = element.get("columns", [])
|
|
84
|
-
if
|
|
111
|
+
if state in {"empty", "error"}:
|
|
112
|
+
if state == "empty":
|
|
113
|
+
pieces.append(f'<text x="{pad + content_width // 2}" y="{y + box_h // 2 + 5}" text-anchor="middle" font-size="14" fill="#64748b">暂无数据</text>')
|
|
114
|
+
elif state == "loading":
|
|
115
|
+
skeleton_width = content_width - 32
|
|
116
|
+
for i, ratio in enumerate((0.9, 0.7, 0.8)):
|
|
117
|
+
pieces.append(f'<rect x="{pad + 16}" y="{y + 34 + i * 28}" width="{int(skeleton_width * ratio)}" height="14" rx="4" fill="#e2e8f0"/>')
|
|
118
|
+
pieces.append(svg_text(pad + 16, y + 132, "加载中…", 14, "#64748b"))
|
|
119
|
+
elif mobile and columns:
|
|
85
120
|
pieces.append(svg_text(pad + 16, y + 36, "列表", 14, weight="700"))
|
|
86
|
-
|
|
121
|
+
first = f'{columns[0].get("label", "")}示例'
|
|
122
|
+
secondary = " · ".join(
|
|
123
|
+
f'{column.get("label", "")}示例'
|
|
124
|
+
for column in columns[1:3]
|
|
125
|
+
if not _is_action_column(column)
|
|
126
|
+
)
|
|
127
|
+
for row in range(2):
|
|
87
128
|
ry = y + 78 + row * 72
|
|
88
|
-
pieces.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
pieces.append(
|
|
129
|
+
pieces.append(svg_text(pad + 16, ry, first))
|
|
130
|
+
if secondary:
|
|
131
|
+
pieces.append(svg_text(pad + 16, ry + 24, secondary, 12, "#64748b"))
|
|
132
|
+
pieces.append(f'<path d="M{pad + 16} {ry + 38} H{content_right - 16}" stroke="#e2e8f0"/>')
|
|
133
|
+
elif not mobile and columns:
|
|
134
|
+
available_width = content_width - 32
|
|
135
|
+
max_cols = max(1, available_width // 110)
|
|
136
|
+
visible_columns = list(columns)
|
|
137
|
+
if len(columns) > max_cols:
|
|
138
|
+
shown = list(columns[:max_cols - 1])
|
|
139
|
+
visible_columns = shown + [{"id": "__overflow__", "label": f"+{len(columns) - len(shown)} 列"}]
|
|
140
|
+
col_w = available_width // len(visible_columns)
|
|
141
|
+
limit = col_w // 14
|
|
142
|
+
for i, column in enumerate(visible_columns):
|
|
143
|
+
pieces.append(svg_text(pad + 16 + i * col_w, y + 36, _truncate(column.get("label", ""), limit), 13, "#475569"))
|
|
92
144
|
for row in range(4):
|
|
93
145
|
ry = y + 74 + row * 54
|
|
94
146
|
pieces.append(f'<path d="M{pad + 16} {ry} H{content_right - 16}" stroke="#e2e8f0"/>')
|
|
95
147
|
if row < 2:
|
|
96
|
-
|
|
148
|
+
for i, column in enumerate(visible_columns):
|
|
149
|
+
if column.get("id") == "__overflow__":
|
|
150
|
+
value, fill = "", "#0f172a"
|
|
151
|
+
elif _is_action_column(column):
|
|
152
|
+
value, fill = "查看", "#2563eb"
|
|
153
|
+
else:
|
|
154
|
+
value, fill = f'{column.get("label", "")}示例', "#0f172a"
|
|
155
|
+
pieces.append(svg_text(pad + 16 + i * col_w, ry + 32, _truncate(value, limit), 13, fill))
|
|
97
156
|
y += box_h + 22
|
|
98
157
|
elif typ == "card_list":
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
pieces.append(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
158
|
+
if state in {"empty", "loading", "error"}:
|
|
159
|
+
box_h = 160
|
|
160
|
+
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
161
|
+
if state == "empty":
|
|
162
|
+
pieces.append(f'<text x="{pad + content_width // 2}" y="{y + box_h // 2 + 5}" text-anchor="middle" font-size="14" fill="#64748b">暂无数据</text>')
|
|
163
|
+
elif state == "loading":
|
|
164
|
+
skeleton_width = content_width - 32
|
|
165
|
+
for i, ratio in enumerate((0.9, 0.7, 0.8)):
|
|
166
|
+
pieces.append(f'<rect x="{pad + 16}" y="{y + 22 + i * 28}" width="{int(skeleton_width * ratio)}" height="14" rx="4" fill="#e2e8f0"/>')
|
|
167
|
+
pieces.append(svg_text(pad + 16, y + 128, "加载中…", 14, "#64748b"))
|
|
168
|
+
y += box_h + 18
|
|
169
|
+
else:
|
|
170
|
+
for item in element.get("items", []):
|
|
171
|
+
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="90" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
172
|
+
pieces.append(svg_text(pad + 20, y + 34, item.get("title"), 14, weight="700"))
|
|
173
|
+
meta = item.get("meta", [])
|
|
174
|
+
pieces.append(svg_text(pad + 20, y + 62, " · ".join(f'{m.get("label")}: {m.get("value")}' for m in meta), 12, "#64748b"))
|
|
175
|
+
y += 108
|
|
105
176
|
elif typ == "tabs":
|
|
106
177
|
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="48" fill="white" stroke="#cbd5e1"/>')
|
|
107
178
|
for i, tab in enumerate(element.get("tabs", [])):
|
|
108
179
|
pieces.append(svg_text(pad + 20 + i * 120, y + 30, tab.get("label"), 14, "#2563eb" if i == 0 else "#64748b"))
|
|
109
180
|
y += 70
|
|
110
181
|
elif typ == "bottom_navigation":
|
|
111
|
-
|
|
112
|
-
bar_y = height - bar_h - 18
|
|
113
|
-
pieces.append(f'<rect x="{pad}" y="{bar_y}" width="{content_width}" height="{bar_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
114
|
-
items = element.get("items", [])
|
|
115
|
-
slot = max(1, (width - 2 * pad) // max(1, len(items)))
|
|
116
|
-
for i, item in enumerate(items):
|
|
117
|
-
pieces.append(f'<text x="{pad + slot * i + slot // 2}" y="{bar_y + 35}" text-anchor="middle" font-size="12" fill="#475569">{text(item.get("label"))}</text>')
|
|
182
|
+
bottom_navigation = element
|
|
118
183
|
elif typ in {"input", "select"}:
|
|
119
184
|
pieces.append(svg_text(pad, y, element.get("label"), 12, "#64748b"))
|
|
120
185
|
pieces.append(f'<rect x="{pad}" y="{y + 10}" width="{content_width}" height="38" fill="white" stroke="#cbd5e1"/>')
|
|
@@ -124,24 +189,152 @@ def render_screen(screen: dict[str, Any], platform: str) -> str:
|
|
|
124
189
|
pieces.append(f'<rect x="{pad}" y="{y}" width="{bw}" height="40" rx="5" fill="#334155"/>')
|
|
125
190
|
pieces.append(f'<text x="{pad + bw // 2}" y="{y + 26}" text-anchor="middle" font-size="13" fill="white">{text(element.get("label"))}</text>')
|
|
126
191
|
y += 58
|
|
127
|
-
|
|
128
|
-
|
|
192
|
+
if state == "error" and not has_data_region and element_index == 0 and not error_drawn:
|
|
193
|
+
error_bar()
|
|
194
|
+
|
|
195
|
+
if not has_data_region and state in {"empty", "loading"}:
|
|
196
|
+
pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="120" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
197
|
+
label = "暂无数据" if state == "empty" else "加载中…"
|
|
198
|
+
pieces.append(f'<text x="{pad + content_width // 2}" y="{y + 65}" text-anchor="middle" font-size="14" fill="#64748b">{label}</text>')
|
|
199
|
+
y += 142
|
|
200
|
+
|
|
201
|
+
y_end = y
|
|
202
|
+
if bottom_navigation is not None:
|
|
203
|
+
y_end += 58 + 18 + 12
|
|
204
|
+
height = max(default_height, y_end + 40)
|
|
205
|
+
|
|
206
|
+
if bottom_navigation is not None:
|
|
207
|
+
bar_h = 58
|
|
208
|
+
bar_y = height - bar_h - 18
|
|
209
|
+
pieces.append(f'<rect x="{pad}" y="{bar_y}" width="{content_width}" height="{bar_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
|
|
210
|
+
items = bottom_navigation.get("items", [])
|
|
211
|
+
slot = max(1, content_width // max(1, len(items)))
|
|
212
|
+
for i, item in enumerate(items):
|
|
213
|
+
pieces.append(f'<text x="{pad + slot * i + slot // 2}" y="{bar_y + 35}" text-anchor="middle" font-size="12" fill="#475569">{text(item.get("label"))}</text>')
|
|
214
|
+
|
|
215
|
+
prefix = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{text(screen.get("title"))} {platform} {state} 页面线框">', f'<rect width="{width}" height="{height}" fill="#f8fafc"/>']
|
|
216
|
+
if overlay:
|
|
217
|
+
if mobile or presentation == "drawer":
|
|
218
|
+
panel_h = height - panel_y
|
|
219
|
+
else:
|
|
220
|
+
panel_h = max(original_panel_h, y_end - panel_y + 30)
|
|
221
|
+
prefix.extend([f'<rect width="{width}" height="{height}" fill="#0f172a33"/>', f'<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" rx="12" fill="white" stroke="#7c3aed" stroke-width="2"/>'])
|
|
222
|
+
return "".join(prefix + pieces + ["</svg>"])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_action_column(column: dict[str, Any]) -> bool:
|
|
226
|
+
return column.get("id") in {"actions", "action", "operation", "operations", "op"} or column.get("label") == "操作"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _truncate(value: Any, limit: int) -> str:
|
|
230
|
+
value = str(value if value is not None else "")
|
|
231
|
+
if len(value) <= limit:
|
|
232
|
+
return value
|
|
233
|
+
if limit <= 1:
|
|
234
|
+
return "…" if limit == 1 else ""
|
|
235
|
+
return value[:limit - 1] + "…"
|
|
129
236
|
|
|
130
237
|
|
|
131
238
|
def render_flow(screens: list[dict[str, Any]]) -> str:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
239
|
+
screen_by_id = {screen.get("id"): screen for screen in screens}
|
|
240
|
+
order = {screen.get("id"): i for i, screen in enumerate(screens)}
|
|
241
|
+
adjacency: dict[str, list[str]] = {screen["id"]: [] for screen in screens}
|
|
242
|
+
indegree = {screen["id"]: 0 for screen in screens}
|
|
243
|
+
first_opener: dict[str, str] = {}
|
|
244
|
+
for screen in screens:
|
|
245
|
+
source = screen["id"]
|
|
246
|
+
for action in screen.get("actions", []):
|
|
247
|
+
target = action.get("target")
|
|
248
|
+
if action.get("type") == "open" and target in screen_by_id:
|
|
249
|
+
adjacency[source].append(target)
|
|
250
|
+
indegree[target] += 1
|
|
251
|
+
first_opener.setdefault(target, source)
|
|
252
|
+
|
|
253
|
+
roots = [screen["id"] for screen in screens if indegree[screen["id"]] == 0]
|
|
254
|
+
if not roots and screens:
|
|
255
|
+
roots = [screens[0]["id"]]
|
|
256
|
+
layers: dict[str, int] = {}
|
|
257
|
+
visit_order: dict[str, int] = {}
|
|
258
|
+
queue: deque[str] = deque()
|
|
259
|
+
|
|
260
|
+
def add_root(root: str) -> None:
|
|
261
|
+
if root not in layers:
|
|
262
|
+
layers[root] = 0
|
|
263
|
+
visit_order[root] = len(visit_order)
|
|
264
|
+
queue.append(root)
|
|
265
|
+
|
|
266
|
+
for root in roots:
|
|
267
|
+
add_root(root)
|
|
268
|
+
while True:
|
|
269
|
+
while queue:
|
|
270
|
+
source = queue.popleft()
|
|
271
|
+
for target in adjacency[source]:
|
|
272
|
+
candidate = layers[source] + 1
|
|
273
|
+
if target not in layers:
|
|
274
|
+
layers[target] = candidate
|
|
275
|
+
visit_order[target] = len(visit_order)
|
|
276
|
+
queue.append(target)
|
|
277
|
+
elif candidate < layers[target]:
|
|
278
|
+
layers[target] = candidate
|
|
279
|
+
queue.append(target)
|
|
280
|
+
unreachable = next((screen["id"] for screen in screens if screen["id"] not in layers), None)
|
|
281
|
+
if unreachable is None:
|
|
282
|
+
break
|
|
283
|
+
add_root(unreachable)
|
|
284
|
+
|
|
285
|
+
by_layer: dict[int, list[str]] = {}
|
|
286
|
+
for screen in screens:
|
|
287
|
+
sid = screen["id"]
|
|
288
|
+
by_layer.setdefault(layers[sid], []).append(sid)
|
|
289
|
+
for layer_ids in by_layer.values():
|
|
290
|
+
layer_ids.sort(key=lambda sid: (visit_order[sid], order[sid]))
|
|
291
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
292
|
+
for layer, layer_ids in by_layer.items():
|
|
293
|
+
for row, sid in enumerate(layer_ids):
|
|
294
|
+
positions[sid] = (40 + layer * 240, 40 + row * 90)
|
|
295
|
+
|
|
296
|
+
layer_count = max(layers.values(), default=-1) + 1
|
|
297
|
+
max_rows = max((len(layer_ids) for layer_ids in by_layer.values()), default=1)
|
|
298
|
+
bottom = 40 + (max_rows - 1) * 90 + 56
|
|
299
|
+
width = max(760, 40 + layer_count * 240 + 40)
|
|
300
|
+
height = max(180, 40 + max_rows * 90 + 40)
|
|
301
|
+
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="页面流转图"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#64748b"/></marker></defs><rect width="100%" height="100%" fill="#f8fafc"/>']
|
|
302
|
+
|
|
303
|
+
back_edge_count = 0
|
|
304
|
+
|
|
305
|
+
def edge(source: str, target: str, dashed: bool = False) -> str:
|
|
306
|
+
nonlocal back_edge_count
|
|
307
|
+
if source == target:
|
|
308
|
+
return ""
|
|
309
|
+
sx, sy = positions[source]
|
|
310
|
+
tx, ty = positions[target]
|
|
311
|
+
source_y, target_y = sy + 28, ty + 28
|
|
312
|
+
if layers[target] > layers[source]:
|
|
313
|
+
path = f'M{sx + 180} {source_y} L{tx} {target_y}'
|
|
314
|
+
else:
|
|
315
|
+
offset_index = back_edge_count % 3
|
|
316
|
+
off = 4 * offset_index
|
|
317
|
+
bend_y = bottom + 12 + 8 * offset_index
|
|
318
|
+
back_edge_count += 1
|
|
319
|
+
path = f'M{sx + 180} {source_y} H{sx + 180 + 20 + off} V{bend_y} H{tx - 20 - off} V{target_y} H{tx}'
|
|
320
|
+
dash = ' stroke-dasharray="6 4"' if dashed else ""
|
|
321
|
+
return f'<path d="{path}" fill="none" stroke="#64748b" stroke-width="2"{dash} marker-end="url(#arrow)"/>'
|
|
322
|
+
|
|
323
|
+
for screen in screens:
|
|
324
|
+
source = screen["id"]
|
|
325
|
+
for action in screen.get("actions", []):
|
|
326
|
+
target = action.get("target")
|
|
327
|
+
if action.get("type") == "open" and target in screen_by_id:
|
|
328
|
+
parts.append(edge(source, target))
|
|
329
|
+
elif action.get("type") in {"close", "back"} and source in first_opener:
|
|
330
|
+
parts.append(edge(source, first_opener[source], True))
|
|
331
|
+
|
|
332
|
+
for screen in screens:
|
|
333
|
+
x, y = positions[screen["id"]]
|
|
137
334
|
modal = screen.get("presentation") in {"modal", "drawer"}
|
|
138
|
-
shape = f'<path d="M{x + 90}
|
|
335
|
+
shape = f'<path d="M{x + 90} {y} L{x + 180} {y + 28} L{x + 90} {y + 56} L{x} {y + 28} Z"' if modal else f'<rect x="{x}" y="{y}" width="180" height="56" rx="8"'
|
|
139
336
|
parts.append(shape + ' fill="#fff" stroke="#334155" stroke-width="2"/>')
|
|
140
|
-
parts.append(f'<text x="{x + 90}" y="
|
|
141
|
-
for action in screen.get("actions", []):
|
|
142
|
-
if action.get("type") == "open" and action.get("target") in ids:
|
|
143
|
-
tx = 40 + ids[action["target"]] * 220
|
|
144
|
-
parts.append(f'<path d="M{x + 180} 90 H{tx}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>')
|
|
337
|
+
parts.append(f'<text x="{x + 90}" y="{y + 34}" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" fill="#0f172a">{mermaid_label(screen.get("title"))}</text>')
|
|
145
338
|
parts.append("</svg>")
|
|
146
339
|
return "".join(parts)
|
|
147
340
|
|
|
@@ -151,10 +344,18 @@ def render_preview(screens: list[dict[str, Any]], feature: str) -> str:
|
|
|
151
344
|
for screen in screens:
|
|
152
345
|
sid = screen["id"].replace("-", "_")
|
|
153
346
|
lines.append(f' {sid}["{mermaid_label(screen["title"])}"]')
|
|
347
|
+
screen_ids = {screen["id"] for screen in screens}
|
|
348
|
+
first_opener: dict[str, str] = {}
|
|
349
|
+
for screen in screens:
|
|
350
|
+
for action in screen.get("actions", []):
|
|
351
|
+
if action.get("type") == "open" and action.get("target") in screen_ids:
|
|
352
|
+
first_opener.setdefault(action["target"], screen["id"])
|
|
154
353
|
for screen in screens:
|
|
155
354
|
for action in screen.get("actions", []):
|
|
156
355
|
if action.get("type") == "open":
|
|
157
356
|
lines.append(f' {screen["id"].replace("-", "_")} -->|{mermaid_label(action.get("id", "open"))}| {action["target"].replace("-", "_")}')
|
|
357
|
+
elif action.get("type") in {"close", "back"} and screen["id"] in first_opener:
|
|
358
|
+
lines.append(f' {screen["id"].replace("-", "_")} -.->|{mermaid_label(action.get("id", action["type"]))}| {first_opener[screen["id"]].replace("-", "_")}')
|
|
158
359
|
lines += ["```", "", "## Screens", ""]
|
|
159
360
|
for screen in screens:
|
|
160
361
|
lines += [f'### {screen["title"]} (`{screen["id"]}`)', "", f'- presentation: `{screen.get("presentation", "page")}`', f'- platforms: `{", ".join(screen.get("platforms", ["web", "mobile"]))}`', f'- states: `{", ".join(screen.get("states", []))}`', "", "| 顺序 | 元素 ID | 类型 | 内容 |", "|---:|---|---|---|"]
|
|
@@ -187,8 +388,8 @@ def gallery_html(manifest: dict[str, Any], assets: dict[str, str], standalone: b
|
|
|
187
388
|
<style>body{{margin:0;background:#eef2f7;color:#0f172a;font:14px system-ui,sans-serif}}header{{padding:18px 24px;background:#fff;border-bottom:1px solid #cbd5e1}}main{{display:flex;min-height:calc(100vh - 70px)}}aside{{width:190px;padding:20px;background:#f8fafc;border-right:1px solid #cbd5e1}}button{{display:block;width:100%;margin:0 0 8px;padding:10px;text-align:left;border:1px solid #cbd5e1;border-radius:6px;background:#fff;cursor:pointer}}button.active{{border-color:#7c3aed;background:#f3e8ff}}section{{flex:1;padding:24px}}.toolbar{{display:flex;gap:8px;margin-bottom:18px}}.toolbar button{{width:auto}}.frame{{padding:20px;background:#dbe3ed;border-radius:10px;min-height:620px}}.frame img{{display:block;max-width:100%;max-height:620px;margin:auto;box-shadow:0 8px 24px #94a3b855;background:#fff}}.meta{{color:#64748b;font-size:12px;margin-top:12px}}</style>
|
|
188
389
|
<script type="application/json" id="gallery-manifest">{embedded_manifest}</script>
|
|
189
390
|
<header><strong>低保真原型画廊</strong><div class="meta">prototype: {esc(manifest['prototype_fingerprint'])} · source: {esc(manifest['source_document_fingerprint'])} · renderer: {esc(manifest['renderer_version'])}</div></header>
|
|
190
|
-
<main><aside id="nav"></aside><section><div class="toolbar"><button id="web">Web</button><button id="mobile">Mobile</button><button id="flow">流程图</button></div><div class="frame"><img id="preview" alt="页面线框"></div><div class="meta" id="state">state: default</div></section></main>
|
|
191
|
-
<script>const manifest=JSON.parse(document.querySelector('#gallery-manifest').textContent);const assets={asset_json};let current=manifest.screens[0].screen_id,platform='web';const nav=document.querySelector('#nav'),img=document.querySelector('#preview'),webBtn=document.querySelector('#web'),mobileBtn=document.querySelector('#mobile');function
|
|
391
|
+
<main><aside id="nav"></aside><section><div class="toolbar"><button id="web">Web</button><button id="mobile">Mobile</button><button id="flow">流程图</button><div class="toolbar" id="state-buttons"></div></div><div class="frame"><img id="preview" alt="页面线框"></div><div class="meta" id="state">state: default</div></section></main>
|
|
392
|
+
<script>const manifest=JSON.parse(document.querySelector('#gallery-manifest').textContent);const assets={asset_json};let current=manifest.screens[0].screen_id,platform='web',state='default';const nav=document.querySelector('#nav'),img=document.querySelector('#preview'),webBtn=document.querySelector('#web'),mobileBtn=document.querySelector('#mobile'),stateButtons=document.querySelector('#state-buttons'),stateLabel=document.querySelector('#state');function screen(){{return manifest.screens.find(x=>x.screen_id===current)}}function states(){{const s=screen();return s&&s.platforms[platform]?s.platforms[platform].states:{{}}}}function ensureState(){{if(!states()[state])state='default'}}manifest.screens.forEach(s=>{{const b=document.createElement('button');b.textContent=s.screen_id;b.dataset.id=s.screen_id;b.onclick=()=>{{current=s.screen_id;if(!s.platforms[platform])platform=Object.keys(s.platforms)[0];ensureState();render()}};nav.append(b)}});function render(){{document.querySelectorAll('aside button').forEach(b=>b.classList.toggle('active',b.dataset.id===current));const s=screen();webBtn.disabled=!s.platforms.web;mobileBtn.disabled=!s.platforms.mobile;ensureState();stateButtons.replaceChildren();Object.keys(states()).forEach(name=>{{const b=document.createElement('button');b.textContent=name;b.classList.toggle('active',name===state);b.onclick=()=>{{state=name;render()}};stateButtons.append(b)}});const p=states()[state];stateLabel.textContent='state: '+state;if(p){{img.src=assets[p]||p;img.alt=current+' '+platform+' '+state}}else{{img.removeAttribute('src');img.alt=current+' '+platform+' 未定义'}}}}webBtn.onclick=()=>{{platform='web';ensureState();render()}};mobileBtn.onclick=()=>{{platform='mobile';ensureState();render()}};document.querySelector('#flow').onclick=()=>{{img.src=assets['flow.svg']||'flow.svg';img.alt='页面流转图'}};render()</script>'''
|
|
192
393
|
|
|
193
394
|
|
|
194
395
|
def main() -> int:
|
|
@@ -202,15 +403,20 @@ def main() -> int:
|
|
|
202
403
|
screens = canonical.get("screens", [])
|
|
203
404
|
canonical_bytes = json.dumps(canonical_order(normalize(canonical)), ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
204
405
|
prototype = "sha256:" + hashlib.sha256(canonical_bytes.encode("utf-8")).hexdigest()
|
|
205
|
-
manifest: dict[str, Any] = {"prototype_fingerprint": prototype, "source_document_fingerprint": args.source_fingerprint, "renderer_version": "1.
|
|
406
|
+
manifest: dict[str, Any] = {"prototype_fingerprint": prototype, "source_document_fingerprint": args.source_fingerprint, "renderer_version": "1.1.0", "generated_at": datetime.now(timezone.utc).isoformat(), "screens": []}
|
|
206
407
|
assets: dict[str, str] = {}
|
|
207
408
|
svg_by_path: dict[str, str] = {"flow.svg": render_flow(screens)}
|
|
208
409
|
for screen in screens:
|
|
209
410
|
entry = {"screen_id": screen["id"], "platforms": {}}
|
|
210
411
|
for platform in screen.get("platforms", ["web", "mobile"]):
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
412
|
+
state_paths = {}
|
|
413
|
+
declared_states = list(screen.get("states") or [])
|
|
414
|
+
render_states = declared_states if "default" in declared_states else ["default", *declared_states]
|
|
415
|
+
for state in render_states:
|
|
416
|
+
path = f"screens/{screen['id']}/{platform}/{state}.svg"
|
|
417
|
+
svg_by_path[path] = render_screen(screen, platform, state)
|
|
418
|
+
state_paths[state] = path
|
|
419
|
+
entry["platforms"][platform] = {"states": state_paths}
|
|
214
420
|
manifest["screens"].append(entry)
|
|
215
421
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
216
422
|
(args.out / "preview.md").write_text(render_preview(screens, canonical.get("feature", "wireframe")), encoding="utf-8")
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: regression-acceptance
|
|
3
|
+
name: Regression Acceptance Workflow
|
|
4
|
+
description: >
|
|
5
|
+
从空闲状态为已上线或存量功能建立并执行一套可追溯的回归验收用例。
|
|
6
|
+
|
|
7
|
+
entry:
|
|
8
|
+
from_flow: idle
|
|
9
|
+
initial_step: regression-test-design
|
|
10
|
+
|
|
11
|
+
governance:
|
|
12
|
+
hard_gate_mode: core
|
|
13
|
+
gate_contract_version: 3
|
|
14
|
+
requirement_graph_gate: enforced
|
|
15
|
+
enforced_checkpoints:
|
|
16
|
+
- CP-3
|
|
17
|
+
step_checkers:
|
|
18
|
+
regression-test-design:
|
|
19
|
+
- change-impact-check
|
|
20
|
+
- test-coverage-check
|
|
21
|
+
- test-obligation-check
|
|
22
|
+
regression-dsl:
|
|
23
|
+
- test-coverage-check
|
|
24
|
+
run-acceptance-tests:
|
|
25
|
+
- acceptance-test-results-check
|
|
26
|
+
|
|
27
|
+
artifact_registry:
|
|
28
|
+
source: ".devflow/planning/artifacts/index.yaml"
|
|
29
|
+
path_key: path
|
|
30
|
+
status_key: status
|
|
31
|
+
pending_statuses:
|
|
32
|
+
- planned
|
|
33
|
+
- pending_rerun
|
|
34
|
+
terminal_success_statuses:
|
|
35
|
+
- approved
|
|
36
|
+
- completed
|
|
37
|
+
- passed
|
|
38
|
+
|
|
39
|
+
state_contract:
|
|
40
|
+
source: ".devflow/planning/STATE.md"
|
|
41
|
+
parser_hint: "read YAML front matter first, then fall back to Markdown sections"
|
|
42
|
+
required_fields:
|
|
43
|
+
- current_flow
|
|
44
|
+
- current_feature
|
|
45
|
+
- current_phase
|
|
46
|
+
- current_step
|
|
47
|
+
- status
|
|
48
|
+
- completed_steps
|
|
49
|
+
- pending_steps
|
|
50
|
+
|
|
51
|
+
variables:
|
|
52
|
+
feature:
|
|
53
|
+
source: state.current_feature
|
|
54
|
+
phase:
|
|
55
|
+
source: state.current_phase
|
|
56
|
+
|
|
57
|
+
checkpoints:
|
|
58
|
+
CP-3:
|
|
59
|
+
name: Regression test suite freeze
|
|
60
|
+
gate_after: regression-test-design
|
|
61
|
+
producing_step: regression-test-design
|
|
62
|
+
required_outputs:
|
|
63
|
+
- ".devflow/planning/requirements/{feature}/tests.json"
|
|
64
|
+
required_meta_output: ".devflow/planning/requirements/{feature}/tests.meta.json"
|
|
65
|
+
required_meta_fields:
|
|
66
|
+
- oracle_source_counts
|
|
67
|
+
- test_case_count
|
|
68
|
+
- candidate_test_suite_graph_revision
|
|
69
|
+
- candidate_test_suite_fingerprint
|
|
70
|
+
summary_fields:
|
|
71
|
+
- test_case_count
|
|
72
|
+
- oracle_source_counts
|
|
73
|
+
- characterization_test_case_count
|
|
74
|
+
prompt: "确认每条回归 TC 的判定标准来源、证据和可复现范围;确认后由 Core 冻结测试集。"
|
|
75
|
+
confirm_options:
|
|
76
|
+
- approve
|
|
77
|
+
- needs_fix
|
|
78
|
+
skip_policy: never
|
|
79
|
+
resume_behavior: "user confirms, then continue with regression-dsl"
|
|
80
|
+
|
|
81
|
+
steps:
|
|
82
|
+
- id: regression-test-design
|
|
83
|
+
order: 10
|
|
84
|
+
label: Regression test design
|
|
85
|
+
skill: workflow-regression-test-design
|
|
86
|
+
depends_on: []
|
|
87
|
+
inputs:
|
|
88
|
+
- ".devflow/planning/code-index/"
|
|
89
|
+
- ".devflow/planning/PROJECT.md"
|
|
90
|
+
outputs:
|
|
91
|
+
- path: ".devflow/planning/requirements/{feature}/tests.json"
|
|
92
|
+
type: acceptance-test-design
|
|
93
|
+
required: true
|
|
94
|
+
- path: ".devflow/planning/features/{feature}/回归验收测试设计.md"
|
|
95
|
+
type: regression-test-design
|
|
96
|
+
required: true
|
|
97
|
+
checkpoint: CP-3
|
|
98
|
+
|
|
99
|
+
- id: regression-dsl
|
|
100
|
+
order: 20
|
|
101
|
+
label: Generate regression DSL cases
|
|
102
|
+
skill: workflow-generate-mainflow-cases
|
|
103
|
+
depends_on:
|
|
104
|
+
- regression-test-design
|
|
105
|
+
inputs:
|
|
106
|
+
- ".devflow/planning/requirements/{feature}/tests.json"
|
|
107
|
+
- ".devflow/planning/code-index/"
|
|
108
|
+
outputs:
|
|
109
|
+
- path: ".devflow/planning/generated-cases/{feature}/"
|
|
110
|
+
type: generated-acceptance-cases
|
|
111
|
+
required: true
|
|
112
|
+
|
|
113
|
+
- id: run-acceptance-tests
|
|
114
|
+
order: 30
|
|
115
|
+
label: Run regression acceptance tests
|
|
116
|
+
skill: workflow-run-acceptance-tests
|
|
117
|
+
depends_on:
|
|
118
|
+
- regression-dsl
|
|
119
|
+
inputs:
|
|
120
|
+
- ".devflow/planning/requirements/{feature}/tests.json"
|
|
121
|
+
- ".devflow/planning/features/{feature}/acceptance-test-bindings.json"
|
|
122
|
+
outputs:
|
|
123
|
+
- path: ".devflow/planning/test-runs/{feature}/acceptance-results.json"
|
|
124
|
+
type: acceptance-test-results
|
|
125
|
+
required: true
|
|
126
|
+
|