design-playbook 0.7.0
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/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- package/skills/ux-spec/references/spec-template.md +43 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract tests for the Preview decision transaction."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
import transaction # noqa: E402
|
|
16
|
+
from transaction import ( # noqa: E402
|
|
17
|
+
PreviewTransactionError,
|
|
18
|
+
TransactionConflict,
|
|
19
|
+
run_preview_transaction,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PreviewDecisionTransactionTests(unittest.TestCase):
|
|
24
|
+
def test_confirm_pass_commits_confirm_and_audit_artifacts(self) -> None:
|
|
25
|
+
seen: list[tuple[Path, str, list[str], int]] = []
|
|
26
|
+
|
|
27
|
+
def collect(
|
|
28
|
+
prototype: Path, summary: str, options: list[str], round_n: int
|
|
29
|
+
) -> dict:
|
|
30
|
+
seen.append((prototype, summary, options, round_n))
|
|
31
|
+
return {
|
|
32
|
+
"choice": "确认通过",
|
|
33
|
+
"feedback": "层级清晰",
|
|
34
|
+
"anchors": [],
|
|
35
|
+
"aborted": False,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
39
|
+
prototype = Path(tmp) / "round-1.html"
|
|
40
|
+
prototype.write_text("<html><body>reviewed</body></html>", encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
result = run_preview_transaction(
|
|
43
|
+
path_arg=str(prototype),
|
|
44
|
+
html=None,
|
|
45
|
+
summary=" review hierarchy ",
|
|
46
|
+
round_n=1,
|
|
47
|
+
report_ref=" report.md ",
|
|
48
|
+
options=["确认通过", "需要修改"],
|
|
49
|
+
collect=collect,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
confirm_path = Path(result["confirm_record_path"])
|
|
53
|
+
confirm = json.loads(confirm_path.read_text(encoding="utf-8"))
|
|
54
|
+
log = (Path(tmp) / "log.md").read_text(encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
seen,
|
|
58
|
+
[(prototype, "review hierarchy", ["确认通过", "需要修改"], 1)],
|
|
59
|
+
)
|
|
60
|
+
self.assertEqual(
|
|
61
|
+
result,
|
|
62
|
+
{
|
|
63
|
+
"confirmed": True,
|
|
64
|
+
"floor_pass": True,
|
|
65
|
+
"selected_options": ["确认通过"],
|
|
66
|
+
"feedback": "层级清晰",
|
|
67
|
+
"anchors": [],
|
|
68
|
+
"round": 1,
|
|
69
|
+
"confirm_record_path": str(confirm_path),
|
|
70
|
+
"aborted": False,
|
|
71
|
+
"decision_id": result["decision_id"],
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
self.assertEqual(len(result["decision_id"]), 32)
|
|
75
|
+
self.assertEqual(confirm["decision_id"], result["decision_id"])
|
|
76
|
+
self.assertTrue(confirm["confirmed"])
|
|
77
|
+
self.assertTrue(confirm["floor_pass"])
|
|
78
|
+
self.assertEqual(confirm["selected_options"], ["确认通过"])
|
|
79
|
+
self.assertIn("- selected: 确认通过", log)
|
|
80
|
+
self.assertIn("- floor_pass: true", log)
|
|
81
|
+
|
|
82
|
+
def test_confirm_floor_failure_records_non_authoritative_attempt(self) -> None:
|
|
83
|
+
result, confirm, log = self._run_submission(
|
|
84
|
+
{"choice": "确认通过", "feedback": "", "anchors": [], "aborted": False}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self.assertFalse(result["confirmed"])
|
|
88
|
+
self.assertFalse(result["floor_pass"])
|
|
89
|
+
self.assertTrue(confirm["confirmed"] is False)
|
|
90
|
+
self.assertIn("confirm with no substantive feedback", confirm["floor_failure"])
|
|
91
|
+
self.assertIn("- floor_pass: false", log)
|
|
92
|
+
|
|
93
|
+
def test_revise_is_audited_without_confirm_record(self) -> None:
|
|
94
|
+
result, confirm, log = self._run_submission(
|
|
95
|
+
{"choice": "需要修改", "feedback": "调整间距", "anchors": [], "aborted": False}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.assertFalse(result["confirmed"])
|
|
99
|
+
self.assertTrue(result["floor_pass"])
|
|
100
|
+
self.assertEqual(result["selected_options"], ["需要修改"])
|
|
101
|
+
self.assertIsNone(confirm)
|
|
102
|
+
self.assertIn("- selected: 需要修改", log)
|
|
103
|
+
|
|
104
|
+
def test_abort_is_audited_without_confirm_record(self) -> None:
|
|
105
|
+
result, confirm, log = self._run_submission(
|
|
106
|
+
{"choice": "__abort__", "feedback": "", "anchors": [], "aborted": True}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.assertFalse(result["confirmed"])
|
|
110
|
+
self.assertEqual(result["selected_options"], [])
|
|
111
|
+
self.assertTrue(result["aborted"])
|
|
112
|
+
self.assertIsNone(confirm)
|
|
113
|
+
self.assertIn("- aborted: true", log)
|
|
114
|
+
|
|
115
|
+
def test_rejected_submission_fails_closed_and_is_audited(self) -> None:
|
|
116
|
+
result, confirm, log = self._run_submission(
|
|
117
|
+
{
|
|
118
|
+
"choice": "",
|
|
119
|
+
"feedback": "forged",
|
|
120
|
+
"anchors": [],
|
|
121
|
+
"aborted": True,
|
|
122
|
+
"rejected": True,
|
|
123
|
+
"rejection": "invalid_token",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self.assertFalse(result["confirmed"])
|
|
128
|
+
self.assertFalse(result["floor_pass"])
|
|
129
|
+
self.assertEqual(result["selected_options"], [])
|
|
130
|
+
self.assertIsNone(confirm)
|
|
131
|
+
self.assertIn("- rejected: true", log)
|
|
132
|
+
self.assertIn("- rejection: invalid_token", log)
|
|
133
|
+
|
|
134
|
+
def test_same_binding_retry_repairs_without_collecting_again(self) -> None:
|
|
135
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
136
|
+
prototype = Path(tmp) / "round-1.html"
|
|
137
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
138
|
+
calls = 0
|
|
139
|
+
|
|
140
|
+
def collect(*args: object) -> dict:
|
|
141
|
+
nonlocal calls
|
|
142
|
+
calls += 1
|
|
143
|
+
return {
|
|
144
|
+
"choice": "确认通过", "feedback": "清晰",
|
|
145
|
+
"anchors": [], "aborted": False,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
first = self._run(prototype, collect=collect)
|
|
149
|
+
(Path(tmp) / "confirm-round-1.json").unlink()
|
|
150
|
+
(Path(tmp) / "log.md").write_text("corrupt projection", encoding="utf-8")
|
|
151
|
+
repaired = self._run(prototype, collect=collect)
|
|
152
|
+
|
|
153
|
+
self.assertEqual(calls, 1)
|
|
154
|
+
self.assertEqual(repaired["decision_id"], first["decision_id"])
|
|
155
|
+
self.assertTrue(Path(repaired["confirm_record_path"]).is_file())
|
|
156
|
+
log = (Path(tmp) / "log.md").read_text(encoding="utf-8")
|
|
157
|
+
self.assertEqual(log.count(first["decision_id"]), 1)
|
|
158
|
+
self.assertNotIn("corrupt projection", log)
|
|
159
|
+
|
|
160
|
+
def test_changed_binding_and_legacy_confirm_fail_closed(self) -> None:
|
|
161
|
+
variants = (
|
|
162
|
+
{"summary": "changed"},
|
|
163
|
+
{"report_ref": "other.md"},
|
|
164
|
+
{"options": ["需要修改", "确认通过"]},
|
|
165
|
+
)
|
|
166
|
+
for variant in variants:
|
|
167
|
+
with self.subTest(variant=variant), tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
prototype = Path(tmp) / "round-1.html"
|
|
169
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
170
|
+
self._run(prototype)
|
|
171
|
+
with self.assertRaisesRegex(TransactionConflict, "use next round"):
|
|
172
|
+
self._run(prototype, **variant)
|
|
173
|
+
|
|
174
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
175
|
+
prototype = Path(tmp) / "round-1.html"
|
|
176
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
177
|
+
self._run(prototype)
|
|
178
|
+
prototype.write_text("changed bytes", encoding="utf-8")
|
|
179
|
+
with self.assertRaisesRegex(TransactionConflict, "use next round"):
|
|
180
|
+
self._run(prototype)
|
|
181
|
+
|
|
182
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
183
|
+
prototype = Path(tmp) / "round-1.html"
|
|
184
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
185
|
+
(Path(tmp) / "confirm-round-1.json").write_text("{}", encoding="utf-8")
|
|
186
|
+
with self.assertRaisesRegex(TransactionConflict, "legacy confirm"):
|
|
187
|
+
self._run(prototype)
|
|
188
|
+
|
|
189
|
+
def test_malformed_decision_metadata_fails_closed(self) -> None:
|
|
190
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
191
|
+
prototype = Path(tmp) / "round-1.html"
|
|
192
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
193
|
+
(Path(tmp) / "decision-round-1.json").write_text(
|
|
194
|
+
"{not json", encoding="utf-8"
|
|
195
|
+
)
|
|
196
|
+
with self.assertRaisesRegex(TransactionConflict, "metadata is unreadable"):
|
|
197
|
+
self._run(prototype)
|
|
198
|
+
|
|
199
|
+
def test_projection_failures_repair_from_committed_entry(self) -> None:
|
|
200
|
+
for failed_name in ("confirm-round-1.json", "log.md"):
|
|
201
|
+
with self.subTest(failed_name=failed_name), tempfile.TemporaryDirectory() as tmp:
|
|
202
|
+
prototype = Path(tmp) / "round-1.html"
|
|
203
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
204
|
+
calls = 0
|
|
205
|
+
|
|
206
|
+
def collect(*args: object) -> dict:
|
|
207
|
+
nonlocal calls
|
|
208
|
+
calls += 1
|
|
209
|
+
return {
|
|
210
|
+
"choice": "确认通过", "feedback": "清晰",
|
|
211
|
+
"anchors": [], "aborted": False,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
real_write = transaction._atomic_write
|
|
215
|
+
failed = False
|
|
216
|
+
|
|
217
|
+
def flaky_write(path: Path, content: str) -> None:
|
|
218
|
+
nonlocal failed
|
|
219
|
+
if path.name == failed_name and not failed:
|
|
220
|
+
failed = True
|
|
221
|
+
raise OSError(f"injected {failed_name} failure")
|
|
222
|
+
real_write(path, content)
|
|
223
|
+
|
|
224
|
+
with mock.patch.object(transaction, "_atomic_write", side_effect=flaky_write):
|
|
225
|
+
with self.assertRaises(PreviewTransactionError) as caught:
|
|
226
|
+
self._run(prototype, collect=collect)
|
|
227
|
+
self.assertTrue(caught.exception.details["retryable"])
|
|
228
|
+
self.assertEqual(
|
|
229
|
+
Path(caught.exception.details["artifact"]).name, failed_name
|
|
230
|
+
)
|
|
231
|
+
result = self._run(prototype, collect=collect)
|
|
232
|
+
self.assertEqual(calls, 1)
|
|
233
|
+
self.assertTrue(Path(result["confirm_record_path"]).is_file())
|
|
234
|
+
self.assertTrue((Path(tmp) / "log.md").is_file())
|
|
235
|
+
|
|
236
|
+
def test_active_round_lock_fails_fast_without_second_collector(self) -> None:
|
|
237
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
238
|
+
prototype = Path(tmp) / "round-1.html"
|
|
239
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
240
|
+
entered = threading.Event()
|
|
241
|
+
release = threading.Event()
|
|
242
|
+
first_error: list[BaseException] = []
|
|
243
|
+
|
|
244
|
+
def blocking_collect(*args: object) -> dict:
|
|
245
|
+
entered.set()
|
|
246
|
+
release.wait(3)
|
|
247
|
+
return {
|
|
248
|
+
"choice": "需要修改", "feedback": "调整",
|
|
249
|
+
"anchors": [], "aborted": False,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def run_first() -> None:
|
|
253
|
+
try:
|
|
254
|
+
self._run(prototype, collect=blocking_collect)
|
|
255
|
+
except BaseException as exc:
|
|
256
|
+
first_error.append(exc)
|
|
257
|
+
|
|
258
|
+
thread = threading.Thread(target=run_first)
|
|
259
|
+
thread.start()
|
|
260
|
+
self.assertTrue(entered.wait(2))
|
|
261
|
+
second_calls = 0
|
|
262
|
+
|
|
263
|
+
def second_collect(*args: object) -> dict:
|
|
264
|
+
nonlocal second_calls
|
|
265
|
+
second_calls += 1
|
|
266
|
+
return {}
|
|
267
|
+
|
|
268
|
+
with self.assertRaises(PreviewTransactionError) as caught:
|
|
269
|
+
self._run(prototype, collect=second_collect)
|
|
270
|
+
self.assertTrue(caught.exception.details["retryable"])
|
|
271
|
+
self.assertEqual(second_calls, 0)
|
|
272
|
+
release.set()
|
|
273
|
+
thread.join(3)
|
|
274
|
+
self.assertFalse(thread.is_alive())
|
|
275
|
+
self.assertEqual(first_error, [])
|
|
276
|
+
self.assertFalse((Path(tmp) / "decision-round-1.lock").exists())
|
|
277
|
+
|
|
278
|
+
def test_active_transaction_refreshes_heartbeat(self) -> None:
|
|
279
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
280
|
+
prototype = Path(tmp) / "round-1.html"
|
|
281
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
282
|
+
entered = threading.Event()
|
|
283
|
+
release = threading.Event()
|
|
284
|
+
errors: list[BaseException] = []
|
|
285
|
+
|
|
286
|
+
def collect(*args: object) -> dict:
|
|
287
|
+
entered.set()
|
|
288
|
+
release.wait(2)
|
|
289
|
+
return {
|
|
290
|
+
"choice": "需要修改", "feedback": "调整",
|
|
291
|
+
"anchors": [], "aborted": False,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def run() -> None:
|
|
295
|
+
try:
|
|
296
|
+
self._run(prototype, collect=collect)
|
|
297
|
+
except BaseException as exc:
|
|
298
|
+
errors.append(exc)
|
|
299
|
+
|
|
300
|
+
with mock.patch.object(transaction, "LOCK_HEARTBEAT_SECONDS", 0.02):
|
|
301
|
+
thread = threading.Thread(target=run)
|
|
302
|
+
thread.start()
|
|
303
|
+
self.assertTrue(entered.wait(1))
|
|
304
|
+
lock = Path(tmp) / "decision-round-1.lock"
|
|
305
|
+
initial = lock.stat().st_mtime_ns
|
|
306
|
+
deadline = time.time() + 1
|
|
307
|
+
while lock.stat().st_mtime_ns == initial and time.time() < deadline:
|
|
308
|
+
time.sleep(0.01)
|
|
309
|
+
self.assertGreater(lock.stat().st_mtime_ns, initial)
|
|
310
|
+
release.set()
|
|
311
|
+
thread.join(2)
|
|
312
|
+
|
|
313
|
+
self.assertEqual(errors, [])
|
|
314
|
+
self.assertFalse(lock.exists())
|
|
315
|
+
|
|
316
|
+
def test_stale_lock_requires_matching_binding(self) -> None:
|
|
317
|
+
for binding_matches in (True, False):
|
|
318
|
+
with self.subTest(binding_matches=binding_matches), tempfile.TemporaryDirectory() as tmp:
|
|
319
|
+
prototype = Path(tmp) / "round-1.html"
|
|
320
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
321
|
+
digest = transaction._binding(
|
|
322
|
+
round_n=1,
|
|
323
|
+
prototype_hash=transaction.prototype_html_digest(
|
|
324
|
+
prototype.read_bytes()
|
|
325
|
+
),
|
|
326
|
+
report_ref="report.md", summary="summary",
|
|
327
|
+
options=["确认通过", "需要修改"],
|
|
328
|
+
)["digest"]
|
|
329
|
+
lock = Path(tmp) / "decision-round-1.lock"
|
|
330
|
+
lock.write_text(
|
|
331
|
+
json.dumps({
|
|
332
|
+
"owner_id": "dead", "decision_id": "prior",
|
|
333
|
+
"binding_digest": digest if binding_matches else "different",
|
|
334
|
+
}),
|
|
335
|
+
encoding="utf-8",
|
|
336
|
+
)
|
|
337
|
+
stale = time.time() - transaction.LOCK_STALE_SECONDS - 1
|
|
338
|
+
import os
|
|
339
|
+
os.utime(lock, (stale, stale))
|
|
340
|
+
if binding_matches:
|
|
341
|
+
result = self._run(prototype)
|
|
342
|
+
self.assertTrue(result["decision_id"])
|
|
343
|
+
self.assertFalse(lock.exists())
|
|
344
|
+
self.assertFalse(
|
|
345
|
+
lock.with_suffix(lock.suffix + ".recovery").exists()
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
with self.assertRaises(TransactionConflict) as caught:
|
|
349
|
+
self._run(prototype)
|
|
350
|
+
self.assertFalse(caught.exception.details["retryable"])
|
|
351
|
+
|
|
352
|
+
def test_collector_failure_cleans_lock(self) -> None:
|
|
353
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
354
|
+
prototype = Path(tmp) / "round-1.html"
|
|
355
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
356
|
+
|
|
357
|
+
def fail_collect(*args: object) -> dict:
|
|
358
|
+
raise RuntimeError("browser closed")
|
|
359
|
+
|
|
360
|
+
with self.assertRaisesRegex(RuntimeError, "browser closed"):
|
|
361
|
+
self._run(prototype, collect=fail_collect)
|
|
362
|
+
self.assertFalse((Path(tmp) / "decision-round-1.lock").exists())
|
|
363
|
+
|
|
364
|
+
def test_decision_entry_failure_leaves_no_recoverable_authority(self) -> None:
|
|
365
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
366
|
+
prototype = Path(tmp) / "round-1.html"
|
|
367
|
+
prototype.write_text("reviewed", encoding="utf-8")
|
|
368
|
+
real_write = transaction._atomic_write
|
|
369
|
+
|
|
370
|
+
def fail_entry(path: Path, content: str) -> None:
|
|
371
|
+
if path.name == "decision-round-1.json":
|
|
372
|
+
raise OSError("injected entry failure")
|
|
373
|
+
real_write(path, content)
|
|
374
|
+
|
|
375
|
+
with mock.patch.object(transaction, "_atomic_write", side_effect=fail_entry):
|
|
376
|
+
with self.assertRaises(PreviewTransactionError) as caught:
|
|
377
|
+
self._run(prototype)
|
|
378
|
+
self.assertTrue(caught.exception.details["retryable"])
|
|
379
|
+
self.assertEqual(
|
|
380
|
+
Path(caught.exception.details["artifact"]).name,
|
|
381
|
+
"decision-round-1.json",
|
|
382
|
+
)
|
|
383
|
+
self.assertFalse((Path(tmp) / "decision-round-1.json").exists())
|
|
384
|
+
self.assertFalse((Path(tmp) / "confirm-round-1.json").exists())
|
|
385
|
+
self.assertFalse((Path(tmp) / "log.md").exists())
|
|
386
|
+
|
|
387
|
+
def _run(
|
|
388
|
+
self, prototype: Path, *, summary: str = "summary",
|
|
389
|
+
report_ref: str = "report.md", options: list[str] | None = None,
|
|
390
|
+
collect=None,
|
|
391
|
+
) -> dict:
|
|
392
|
+
if collect is None:
|
|
393
|
+
collect = lambda *args: {
|
|
394
|
+
"choice": "确认通过", "feedback": "清晰",
|
|
395
|
+
"anchors": [], "aborted": False,
|
|
396
|
+
}
|
|
397
|
+
return run_preview_transaction(
|
|
398
|
+
path_arg=str(prototype), html=None, summary=summary, round_n=1,
|
|
399
|
+
report_ref=report_ref,
|
|
400
|
+
options=options or ["确认通过", "需要修改"], collect=collect,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def _run_submission(
|
|
404
|
+
self, submission: dict
|
|
405
|
+
) -> tuple[dict, dict | None, str]:
|
|
406
|
+
def collect(
|
|
407
|
+
prototype: Path, summary: str, options: list[str], round_n: int
|
|
408
|
+
) -> dict:
|
|
409
|
+
return submission
|
|
410
|
+
|
|
411
|
+
temp = tempfile.TemporaryDirectory()
|
|
412
|
+
self.addCleanup(temp.cleanup)
|
|
413
|
+
preview_dir = Path(temp.name)
|
|
414
|
+
prototype = preview_dir / "round-1.html"
|
|
415
|
+
prototype.write_text("<html><body>reviewed</body></html>", encoding="utf-8")
|
|
416
|
+
result = run_preview_transaction(
|
|
417
|
+
path_arg=str(prototype),
|
|
418
|
+
html=None,
|
|
419
|
+
summary="summary",
|
|
420
|
+
round_n=1,
|
|
421
|
+
report_ref="report.md",
|
|
422
|
+
options=["确认通过", "需要修改"],
|
|
423
|
+
collect=collect,
|
|
424
|
+
)
|
|
425
|
+
confirm_path = preview_dir / "confirm-round-1.json"
|
|
426
|
+
confirm = (
|
|
427
|
+
json.loads(confirm_path.read_text(encoding="utf-8"))
|
|
428
|
+
if confirm_path.is_file()
|
|
429
|
+
else None
|
|
430
|
+
)
|
|
431
|
+
log = (preview_dir / "log.md").read_text(encoding="utf-8")
|
|
432
|
+
return result, confirm, log
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
unittest.main()
|