test_workflow_manager.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import json
  4. import logging
  5. import sys
  6. from pathlib import Path
  7. from types import SimpleNamespace
  8. def _load_module():
  9. scripts_dir = Path(__file__).resolve().parents[2]
  10. if str(scripts_dir) not in sys.path:
  11. sys.path.insert(0, str(scripts_dir))
  12. import workflow_manager
  13. return workflow_manager
  14. def test_workflow_lifecycle_and_trace(tmp_path, monkeypatch):
  15. module = _load_module()
  16. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  17. webnovel_dir = tmp_path / ".webnovel"
  18. webnovel_dir.mkdir(parents=True, exist_ok=True)
  19. module.start_task("webnovel-write", {"chapter_num": 7})
  20. module.start_step("Step 1", "Context")
  21. module.complete_step("Step 1", json.dumps({"state_json_modified": True}, ensure_ascii=False))
  22. module.complete_task(json.dumps({"review_completed": True}, ensure_ascii=False))
  23. state = module.load_state()
  24. assert state["current_task"] is None
  25. assert state["history"][-1]["status"] == module.TASK_STATUS_COMPLETED
  26. assert state["last_stable_state"]["artifacts"]["review_completed"] is True
  27. trace_path = module.get_call_trace_path()
  28. assert trace_path.exists()
  29. lines = trace_path.read_text(encoding="utf-8").strip().splitlines()
  30. events = [json.loads(line)["event"] for line in lines if line.strip()]
  31. assert "task_started" in events
  32. assert "step_started" in events
  33. assert "step_completed" in events
  34. assert "task_completed" in events
  35. def test_start_task_reentry_increments_retry(tmp_path, monkeypatch):
  36. module = _load_module()
  37. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  38. webnovel_dir = tmp_path / ".webnovel"
  39. webnovel_dir.mkdir(parents=True, exist_ok=True)
  40. module.start_task("webnovel-write", {"chapter_num": 8})
  41. module.start_task("webnovel-write", {"chapter_num": 8})
  42. state = module.load_state()
  43. task = state["current_task"]
  44. assert task is not None
  45. assert task["status"] == module.TASK_STATUS_RUNNING
  46. assert int(task.get("retry_count", 0)) >= 1
  47. def test_complete_step_rejects_mismatch_step_id(tmp_path, monkeypatch):
  48. module = _load_module()
  49. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  50. webnovel_dir = tmp_path / ".webnovel"
  51. webnovel_dir.mkdir(parents=True, exist_ok=True)
  52. module.start_task("webnovel-write", {"chapter_num": 9})
  53. module.start_step("Step 2A", "Draft")
  54. module.complete_step("Step 2B")
  55. state = module.load_state()
  56. current_step = state["current_task"]["current_step"]
  57. assert current_step is not None
  58. assert current_step["id"] == "Step 2A"
  59. assert current_step["status"] == module.STEP_STATUS_RUNNING
  60. def test_workflow_step_owner_and_order_violation_trace(tmp_path, monkeypatch):
  61. module = _load_module()
  62. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  63. webnovel_dir = tmp_path / ".webnovel"
  64. webnovel_dir.mkdir(parents=True, exist_ok=True)
  65. assert module.expected_step_owner("webnovel-write", "Step 1") == "context-agent"
  66. assert module.expected_step_owner("webnovel-write", "Step 5") == "data-agent"
  67. module.start_task("webnovel-write", {"chapter_num": 12})
  68. module.start_step("Step 3", "Review")
  69. trace_path = module.get_call_trace_path()
  70. lines = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines() if line.strip()]
  71. events = [row.get("event") for row in lines]
  72. assert "step_order_violation" in events
  73. step_started = [row for row in lines if row.get("event") == "step_started"]
  74. assert step_started
  75. assert step_started[-1].get("payload", {}).get("expected_owner") == "review-agents"
  76. def test_safe_append_call_trace_logs_failure(monkeypatch, caplog):
  77. module = _load_module()
  78. def _raise_trace_error(event, payload=None):
  79. raise RuntimeError("trace failure")
  80. monkeypatch.setattr(module, "append_call_trace", _raise_trace_error)
  81. with caplog.at_level(logging.WARNING):
  82. module.safe_append_call_trace("unit_test_event", {"ok": True})
  83. message_text = "\n".join(record.getMessage() for record in caplog.records)
  84. assert "failed to append call trace" in message_text
  85. assert "unit_test_event" in message_text
  86. def test_workflow_reentry_does_not_duplicate_history(tmp_path, monkeypatch):
  87. module = _load_module()
  88. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  89. webnovel_dir = tmp_path / ".webnovel"
  90. webnovel_dir.mkdir(parents=True, exist_ok=True)
  91. module.start_task("webnovel-write", {"chapter_num": 20})
  92. module.start_task("webnovel-write", {"chapter_num": 20})
  93. module.start_task("webnovel-write", {"chapter_num": 20})
  94. state = module.load_state()
  95. assert isinstance(state.get("history"), list)
  96. assert len(state.get("history")) == 0
  97. task = state.get("current_task") or {}
  98. assert int(task.get("retry_count", 0)) >= 2
  99. def test_cleanup_artifacts_requires_confirm(tmp_path, monkeypatch):
  100. module = _load_module()
  101. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  102. webnovel_dir = tmp_path / ".webnovel"
  103. webnovel_dir.mkdir(parents=True, exist_ok=True)
  104. draft_path = module.default_chapter_draft_path(tmp_path, 7)
  105. draft_path.parent.mkdir(parents=True, exist_ok=True)
  106. draft_path.write_text("draft", encoding="utf-8")
  107. git_called = {"count": 0}
  108. def _fake_run(*args, **kwargs):
  109. git_called["count"] += 1
  110. return SimpleNamespace(returncode=0, stderr="", stdout="")
  111. monkeypatch.setattr(module.subprocess, "run", _fake_run)
  112. preview = module.cleanup_artifacts(7, confirm=False)
  113. assert draft_path.exists()
  114. assert git_called["count"] == 0
  115. assert any(item.startswith("[预览]") for item in preview)
  116. def test_cleanup_artifacts_confirm_deletes_with_backup(tmp_path, monkeypatch):
  117. module = _load_module()
  118. monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
  119. webnovel_dir = tmp_path / ".webnovel"
  120. webnovel_dir.mkdir(parents=True, exist_ok=True)
  121. draft_path = module.default_chapter_draft_path(tmp_path, 8)
  122. draft_path.parent.mkdir(parents=True, exist_ok=True)
  123. draft_path.write_text("draft", encoding="utf-8")
  124. git_called = {"count": 0, "cmd": None}
  125. def _fake_run(cmd, **kwargs):
  126. git_called["count"] += 1
  127. git_called["cmd"] = cmd
  128. return SimpleNamespace(returncode=0, stderr="", stdout="")
  129. monkeypatch.setattr(module.subprocess, "run", _fake_run)
  130. cleaned = module.cleanup_artifacts(8, confirm=True)
  131. assert not draft_path.exists()
  132. assert git_called["count"] == 1
  133. assert git_called["cmd"] == ["git", "reset", "HEAD", "."]
  134. assert any("Git 暂存区已清理" in item for item in cleaned)
  135. backup_dir = tmp_path / ".webnovel" / "recovery_backups"
  136. backups = list(backup_dir.glob("ch0008-*"))
  137. assert backups