feat(ack): add project knowledge guardrails
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
|
||||
|
||||
|
||||
def valid_knowledge_board() -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-1",
|
||||
"title": "validate knowledge fields",
|
||||
"status": "open",
|
||||
"knowledgeRefs": ["K-001@1"],
|
||||
"knowledgeApplied": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "applied",
|
||||
"evidence": "followed the guardrail",
|
||||
}
|
||||
],
|
||||
"knowledgeCandidates": [
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"title": "candidate",
|
||||
"claim": "the failure is reproducible",
|
||||
"scope": {"components": ["web"]},
|
||||
"appliesWhen": "the web component changes",
|
||||
"directive": "run the reviewed check",
|
||||
"rationale": "avoid the repeated failure",
|
||||
"evidenceRefs": ["tasks.yaml#T-1"],
|
||||
"proposedBy": "developer",
|
||||
"proposedAt": "2026-07-31T10:00:00+08:00",
|
||||
}
|
||||
],
|
||||
"knowledgeChecks": [
|
||||
{
|
||||
"ref": "K-001@1",
|
||||
"result": "passed",
|
||||
"evidence": "independently verified",
|
||||
"checkedBy": "test",
|
||||
"checkedAt": "2026-07-31T10:05:00+08:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class AckTaskValidationTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self,
|
||||
content: str | None = None,
|
||||
*extra_args: str,
|
||||
no_site_packages: bool = False,
|
||||
suffix: str = ".yaml",
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [sys.executable]
|
||||
if no_site_packages:
|
||||
command.append("-S")
|
||||
command.append(str(VALIDATOR))
|
||||
|
||||
if content is None:
|
||||
return subprocess.run(
|
||||
[*command, *extra_args, str(EXAMPLE)],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
task_file = Path(temp_dir) / f"tasks{suffix}"
|
||||
task_file.write_text(textwrap.dedent(content), encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[*command, *extra_args, str(task_file)],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def assert_board_rejected_in_all_modes(
|
||||
self,
|
||||
board: dict,
|
||||
*expected_messages: str,
|
||||
) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
result = self.run_validator(
|
||||
json.dumps(board),
|
||||
no_site_packages=no_site_packages,
|
||||
suffix=".json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout)
|
||||
for message in expected_messages:
|
||||
self.assertIn(message, result.stderr)
|
||||
if no_site_packages:
|
||||
self.assertIn("内置语义规则", result.stderr)
|
||||
self.assertNotIn("[schema]", result.stderr)
|
||||
|
||||
def assert_board_accepted_in_all_modes(self, board: dict) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
result = self.run_validator(
|
||||
json.dumps(board),
|
||||
no_site_packages=no_site_packages,
|
||||
suffix=".json",
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_example_with_knowledge_fields_is_valid(self) -> None:
|
||||
result = self.run_validator()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_knowledge_applied_and_checks_must_reference_selected_knowledge(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid refs
|
||||
status: verified
|
||||
knowledgeRefs: ["K-001@1"]
|
||||
knowledgeApplied:
|
||||
- ref: "K-002@1"
|
||||
result: applied
|
||||
evidence: "used the rule"
|
||||
knowledgeChecks:
|
||||
- ref: "K-003@1"
|
||||
result: failed
|
||||
evidence: "still broken"
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("K-002@1 不在 knowledgeRefs 中", result.stderr)
|
||||
self.assertIn("K-003@1 不在 knowledgeRefs 中", result.stderr)
|
||||
self.assertIn("verified 任务不能保留失败", result.stderr)
|
||||
|
||||
def test_knowledge_refs_require_revision(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid ref
|
||||
status: open
|
||||
knowledgeRefs: ["K-001"]
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("K-<id>@<revision>", result.stderr)
|
||||
|
||||
def test_candidate_requires_actionable_scope_and_evidence(self) -> None:
|
||||
result = self.run_validator(
|
||||
"""
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: invalid candidate
|
||||
status: open
|
||||
knowledgeCandidates:
|
||||
- kind: guess
|
||||
title: maybe
|
||||
claim: uncertain
|
||||
scope: {}
|
||||
appliesWhen: sometimes
|
||||
directive: retry
|
||||
rationale: unknown
|
||||
evidenceRefs: []
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn(".kind: 必须是", result.stderr)
|
||||
self.assertIn(".scope: 至少包含一个非空作用域", result.stderr)
|
||||
self.assertIn(".evidenceRefs: 必须是非空字符串列表", result.stderr)
|
||||
|
||||
def test_forbidden_knowledge_properties_fail_with_and_without_jsonschema(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
task = board["tasks"][0]
|
||||
task["knowledgeApplied"][0]["unexpectedApplication"] = True
|
||||
task["knowledgeCandidates"][0]["unexpectedCandidate"] = True
|
||||
task["knowledgeChecks"][0]["unexpectedCheck"] = True
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeApplied[0]: 未知字段 'unexpectedApplication'",
|
||||
"knowledgeCandidates[0]: 未知字段 'unexpectedCandidate'",
|
||||
"knowledgeChecks[0]: 未知字段 'unexpectedCheck'",
|
||||
)
|
||||
|
||||
def test_valid_optional_knowledge_fields_pass_in_all_modes(self) -> None:
|
||||
self.assert_board_accepted_in_all_modes(valid_knowledge_board())
|
||||
|
||||
def test_explicit_null_knowledge_collections_fail_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-REFS",
|
||||
"title": "null refs",
|
||||
"status": "open",
|
||||
"knowledgeRefs": None,
|
||||
},
|
||||
{
|
||||
"id": "T-APPLIED",
|
||||
"title": "null applications",
|
||||
"status": "open",
|
||||
"knowledgeApplied": None,
|
||||
},
|
||||
{
|
||||
"id": "T-CANDIDATES",
|
||||
"title": "null candidates",
|
||||
"status": "open",
|
||||
"knowledgeCandidates": None,
|
||||
},
|
||||
{
|
||||
"id": "T-CHECKS",
|
||||
"title": "null checks",
|
||||
"status": "open",
|
||||
"knowledgeChecks": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeRefs: 必须是列表",
|
||||
"knowledgeApplied: 必须是列表",
|
||||
"knowledgeCandidates: 必须是列表",
|
||||
"knowledgeChecks: 必须是列表",
|
||||
)
|
||||
|
||||
def test_knowledge_item_types_and_blank_evidence_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
task = board["tasks"][0]
|
||||
task["knowledgeApplied"][0].update(
|
||||
{"ref": 1, "result": "unknown", "evidence": " "}
|
||||
)
|
||||
task["knowledgeChecks"][0].update(
|
||||
{"ref": False, "result": "unknown", "evidence": "\t"}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeApplied[0].ref: 必须使用 K-<id>@<revision> 格式",
|
||||
"knowledgeApplied[0].result: 必须是 applied/not_applicable",
|
||||
"knowledgeApplied[0].evidence: 必须提供非空证据",
|
||||
"knowledgeChecks[0].ref: 必须使用 K-<id>@<revision> 格式",
|
||||
"knowledgeChecks[0].result: 必须是",
|
||||
"knowledgeChecks[0].evidence: 必须提供非空证据",
|
||||
)
|
||||
|
||||
def test_candidate_text_scope_and_evidence_refs_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
candidate = board["tasks"][0]["knowledgeCandidates"][0]
|
||||
candidate.update(
|
||||
{
|
||||
"title": 1,
|
||||
"claim": " ",
|
||||
"appliesWhen": [],
|
||||
"directive": "",
|
||||
"rationale": None,
|
||||
"scope": {
|
||||
"components": ["web", "web"],
|
||||
"paths": [" "],
|
||||
},
|
||||
"evidenceRefs": ["tasks.yaml#T-1", "tasks.yaml#T-1"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].title: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].claim: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].appliesWhen: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].directive: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].rationale: 必须是非空字符串",
|
||||
"knowledgeCandidates[0].scope.components: 不能包含重复值",
|
||||
"knowledgeCandidates[0].scope.paths: 必须是非空字符串列表",
|
||||
"knowledgeCandidates[0].evidenceRefs: 不能包含重复值",
|
||||
)
|
||||
|
||||
def test_candidate_evidence_refs_reject_blank_strings_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0]["knowledgeCandidates"][0]["evidenceRefs"] = [" "]
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].evidenceRefs: 必须是非空字符串列表",
|
||||
)
|
||||
|
||||
def test_optional_knowledge_field_types_fail_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
candidate = board["tasks"][0]["knowledgeCandidates"][0]
|
||||
candidate["proposedBy"] = 1
|
||||
candidate["proposedAt"] = []
|
||||
check = board["tasks"][0]["knowledgeChecks"][0]
|
||||
check["checkedBy"] = False
|
||||
check["checkedAt"] = {}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"knowledgeCandidates[0].proposedBy: 必须是字符串",
|
||||
"knowledgeCandidates[0].proposedAt: 必须是字符串",
|
||||
"knowledgeChecks[0].checkedBy: 必须是字符串",
|
||||
"knowledgeChecks[0].checkedAt: 必须是字符串",
|
||||
)
|
||||
|
||||
def test_attempt_id_must_match_task_and_round_and_be_unique(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "invalid attempt ids",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "OTHER-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "OTHER-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"attemptId": "invalid/attempt",
|
||||
"result": "failed",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch.rounds[0].attemptId: 应为 BUG-017-A1",
|
||||
"dispatch.rounds[1].attemptId: 轮次内不能重复: OTHER-A1",
|
||||
"dispatch.rounds[2].attemptId: 必须使用 <task-id>-A<round> 格式",
|
||||
)
|
||||
|
||||
def test_valid_optional_attempt_ids_pass_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "valid attempt ids",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "BUG-017-A1",
|
||||
"result": "failed",
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"attemptId": "BUG-017-A2",
|
||||
"result": "failed",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_boolean_version_fails_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["version"] = True
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"version 必须是 >=1 的整数",
|
||||
)
|
||||
|
||||
def test_knowledge_file_is_fixed_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["project"]["knowledgeFile"] = "docs/ack/alternate.yaml"
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml",
|
||||
)
|
||||
|
||||
def test_basic_identifiers_must_be_nonempty_strings_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["project"]["name"] = 7
|
||||
board["tasks"][0]["id"] = 9
|
||||
board["tasks"][0]["title"] = " "
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"project.name 必须是非空字符串",
|
||||
"id 必须是非空字符串",
|
||||
"title 必须是非空字符串",
|
||||
)
|
||||
|
||||
def test_root_project_and_summary_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
{
|
||||
"updatedAt": [],
|
||||
"source": {},
|
||||
"ackVersion": 1,
|
||||
"kitVersion": False,
|
||||
"summary": {
|
||||
"verified": [1],
|
||||
"open": {},
|
||||
"failedRetest": [False],
|
||||
"leftovers": None,
|
||||
},
|
||||
"statusReference": [],
|
||||
}
|
||||
)
|
||||
board["project"].update(
|
||||
{
|
||||
"repoPath": [],
|
||||
"baseUrl": {},
|
||||
"devWorktree": 1,
|
||||
"overlayFile": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"<root>.updatedAt: 必须是字符串",
|
||||
"<root>.source: 必须是字符串",
|
||||
"<root>.ackVersion: 必须是字符串",
|
||||
"<root>.kitVersion: 必须是字符串",
|
||||
"project.repoPath: 必须是字符串",
|
||||
"project.baseUrl: 必须是字符串",
|
||||
"project.devWorktree: 必须是字符串",
|
||||
"project.overlayFile: 必须是字符串",
|
||||
"summary.verified: 列表项必须是字符串",
|
||||
"summary.open: 必须是列表",
|
||||
"summary.failedRetest: 列表项必须是字符串",
|
||||
"summary.leftovers: 必须是列表",
|
||||
"statusReference 必须是对象",
|
||||
)
|
||||
|
||||
def test_task_optional_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"type": [],
|
||||
"priority": {},
|
||||
"assignee": False,
|
||||
"component": 1,
|
||||
"specRefs": {},
|
||||
"testRefs": [1],
|
||||
"description": [],
|
||||
"stepsToReproduce": [{}],
|
||||
"expected": False,
|
||||
"actual": None,
|
||||
"evidence": [],
|
||||
"verification": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
".type: 必须是字符串",
|
||||
".priority: 必须是字符串",
|
||||
".assignee: 必须是字符串",
|
||||
".component: 必须是字符串",
|
||||
".specRefs: 必须是列表",
|
||||
".testRefs: 列表项必须是字符串",
|
||||
".description: 必须是字符串",
|
||||
".stepsToReproduce: 列表项必须是字符串",
|
||||
".expected: 必须是字符串",
|
||||
".actual: 必须是字符串",
|
||||
".evidence: 必须是对象",
|
||||
".verification: 必须是对象",
|
||||
)
|
||||
|
||||
def test_dispatch_and_resolution_types_match_schema_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"dispatch": {
|
||||
"taskId": [],
|
||||
"dispatchId": {},
|
||||
"worker": False,
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"result": "failed",
|
||||
"evidence": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
"resolution": {
|
||||
"fixedBy": [],
|
||||
"verifiedBy": {},
|
||||
"verifiedAt": False,
|
||||
"leftoverReason": 1,
|
||||
"evidence": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
".dispatch.taskId: 必须是字符串或 null",
|
||||
".dispatch.dispatchId: 必须是字符串或 null",
|
||||
".dispatch.worker: 必须是字符串或 null",
|
||||
".dispatch.rounds[0].evidence: 必须是字符串",
|
||||
".resolution.fixedBy: 必须是字符串或 null",
|
||||
".resolution.verifiedBy: 必须是字符串或 null",
|
||||
".resolution.verifiedAt: 必须是字符串或 null",
|
||||
".resolution.leftoverReason: 必须是字符串或 null",
|
||||
".resolution.evidence: 必须是对象",
|
||||
)
|
||||
|
||||
def test_explicit_null_structures_fail_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-DISPATCH",
|
||||
"title": "invalid dispatch",
|
||||
"status": "open",
|
||||
"dispatch": None,
|
||||
},
|
||||
{
|
||||
"id": "T-ROUNDS",
|
||||
"title": "invalid rounds",
|
||||
"status": "open",
|
||||
"dispatch": {"rounds": None},
|
||||
},
|
||||
{
|
||||
"id": "T-RESOLUTION",
|
||||
"title": "invalid resolution",
|
||||
"status": "open",
|
||||
"resolution": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"T-DISPATCH.dispatch: 必须是对象",
|
||||
"T-ROUNDS.dispatch.rounds: 必须是列表",
|
||||
"T-RESOLUTION.resolution: 必须是对象",
|
||||
)
|
||||
|
||||
def test_valid_optional_schema_fields_pass_in_all_modes(self) -> None:
|
||||
board = valid_knowledge_board()
|
||||
board.update(
|
||||
{
|
||||
"updatedAt": "2026-07-31T10:00:00+08:00",
|
||||
"source": "manual",
|
||||
"ackVersion": "0.9.0",
|
||||
"kitVersion": "0.8.0",
|
||||
"summary": {
|
||||
"verified": ["T-1"],
|
||||
"open": [],
|
||||
"failedRetest": [],
|
||||
"leftovers": [],
|
||||
},
|
||||
"statusReference": {},
|
||||
}
|
||||
)
|
||||
board["project"].update(
|
||||
{
|
||||
"repoPath": "/repo",
|
||||
"baseUrl": "http://127.0.0.1:3000",
|
||||
"devWorktree": "/repo-dev",
|
||||
"overlayFile": "docs/ack/project.md",
|
||||
"knowledgeFile": "docs/ack/knowledge.yaml",
|
||||
}
|
||||
)
|
||||
board["tasks"][0].update(
|
||||
{
|
||||
"type": "bug",
|
||||
"priority": "P1",
|
||||
"assignee": "developer",
|
||||
"component": "web",
|
||||
"specRefs": ["spec.md"],
|
||||
"testRefs": ["tests/test_web.py"],
|
||||
"description": "description",
|
||||
"stepsToReproduce": ["open page"],
|
||||
"expected": "works",
|
||||
"actual": "fails",
|
||||
"evidence": {},
|
||||
"verification": {},
|
||||
"dispatch": {
|
||||
"taskId": "orca-task",
|
||||
"dispatchId": None,
|
||||
"worker": "worker-1",
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"attemptId": "T-1-A1",
|
||||
"result": "failed",
|
||||
"evidence": "test output",
|
||||
}
|
||||
],
|
||||
},
|
||||
"resolution": {
|
||||
"fixedBy": "developer",
|
||||
"verifiedBy": None,
|
||||
"verifiedAt": None,
|
||||
"leftoverReason": None,
|
||||
"evidence": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
def test_round_numbers_must_be_contiguous_and_within_budget(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "BUG-017",
|
||||
"title": "invalid round number",
|
||||
"status": "failed_retest",
|
||||
"dispatch": {
|
||||
"rounds": [
|
||||
{
|
||||
"round": 999,
|
||||
"attemptId": "BUG-017-A999",
|
||||
"result": "failed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"dispatch.rounds[0].round: 必须是 1..3 的整数",
|
||||
"dispatch.rounds: round 必须从 1 连续递增且不重复",
|
||||
)
|
||||
|
||||
def test_leftover_reason_must_be_nonempty_string_in_all_modes(self) -> None:
|
||||
board = {
|
||||
"version": 1,
|
||||
"project": {"name": "demo"},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T-1",
|
||||
"title": "invalid leftover reason",
|
||||
"status": "leftover",
|
||||
"resolution": {"leftoverReason": True},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"leftover 必须填 resolution.leftoverReason",
|
||||
)
|
||||
|
||||
def test_explicit_missing_schema_is_an_environment_error(self) -> None:
|
||||
result = self.run_validator(None, "--schema", "/definitely/missing/schema.json")
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("找不到指定的 schema 文件", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user