354 lines
11 KiB
Python
354 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from helpers import FIXTURES, init_run, read_json, run_cli
|
|
|
|
|
|
def test_write_auto_is_at_least_standard_and_sequences_are_unique(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("짧은 새 문서 요청\n", encoding="utf-8")
|
|
workspace = tmp_path / "runs"
|
|
command = (
|
|
"--brief",
|
|
brief,
|
|
"--kind",
|
|
"explanation",
|
|
"--route",
|
|
"auto",
|
|
"--workspace",
|
|
workspace,
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
first = run_cli("init_run.py", *command)
|
|
second = run_cli("init_run.py", *command)
|
|
assert first.returncode == second.returncode == 0
|
|
first_dir = Path(first.stdout.split("\t", 1)[0])
|
|
second_dir = Path(second.stdout.split("\t", 1)[0])
|
|
assert first_dir.name == "2026-07-23-001"
|
|
assert second_dir.name == "2026-07-23-002"
|
|
first_manifest = read_json(first_dir / "00_run.json")
|
|
assert first_manifest["route_hint"] == "standard"
|
|
assert first_manifest["omissions"] == []
|
|
assert first_manifest["error"] is None
|
|
assert first_manifest["history"][0]["error"] is None
|
|
rules_path = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "skills"
|
|
/ "technical-doc-flow"
|
|
/ "config"
|
|
/ "quality-rules.json"
|
|
)
|
|
assert first_manifest["rules_sha256"] == hashlib.sha256(
|
|
rules_path.read_bytes()
|
|
).hexdigest()
|
|
|
|
|
|
def test_dangling_run_symlink_is_occupied_and_preserved(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("짧은 새 문서 요청\n", encoding="utf-8")
|
|
workspace = tmp_path / "runs"
|
|
workspace.mkdir()
|
|
missing_target = tmp_path / "missing-run-target"
|
|
occupied = workspace / "2026-07-23-001"
|
|
occupied.symlink_to(missing_target, target_is_directory=True)
|
|
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--kind",
|
|
"explanation",
|
|
"--route",
|
|
"auto",
|
|
"--workspace",
|
|
workspace,
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
run_dir = Path(result.stdout.split("\t", 1)[0])
|
|
assert run_dir.name == "2026-07-23-002"
|
|
assert occupied.is_symlink()
|
|
assert occupied.readlink() == missing_target
|
|
assert not occupied.exists()
|
|
|
|
|
|
def test_revise_preserves_brief_and_draft_snapshots_and_auto_light(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
draft = tmp_path / "draft.md"
|
|
source = tmp_path / "source.txt"
|
|
brief.write_text("이 초안의 용어를 쉽게 풀어 주세요.\n", encoding="utf-8")
|
|
draft.write_text("# 기존 문서\n\n짧은 본문입니다.\n", encoding="utf-8")
|
|
source.write_text("근거 snapshot\n", encoding="utf-8")
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--draft",
|
|
draft,
|
|
"--source",
|
|
source,
|
|
"--kind",
|
|
"explanation",
|
|
"--route",
|
|
"auto",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
run_dir = Path(result.stdout.split("\t", 1)[0])
|
|
manifest = read_json(run_dir / "00_run.json")
|
|
sources = read_json(run_dir / "01_sources.json")
|
|
combined = (run_dir / "01_input.md").read_bytes()
|
|
assert manifest["route_hint"] == "light"
|
|
assert b"technical-doc-flow:brief:start" in combined
|
|
assert b"technical-doc-flow:draft:start" in combined
|
|
assert manifest["inputs"]["brief_sha256"] == hashlib.sha256(brief.read_bytes()).hexdigest()
|
|
assert manifest["inputs"]["draft_sha256"] == hashlib.sha256(draft.read_bytes()).hexdigest()
|
|
assert manifest["inputs"]["input_sha256"] == hashlib.sha256(combined).hexdigest()
|
|
serialized_sources = json.dumps(sources, ensure_ascii=False, indent=2).encode() + b"\n"
|
|
assert manifest["inputs"]["sources_manifest_sha256"] == hashlib.sha256(serialized_sources).hexdigest()
|
|
|
|
|
|
def test_auto_deep_for_many_sources(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
draft = tmp_path / "draft.md"
|
|
brief.write_text("정밀 검토\n", encoding="utf-8")
|
|
draft.write_text("# 초안\n", encoding="utf-8")
|
|
sources = []
|
|
for index in range(9):
|
|
path = tmp_path / f"source-{index}.txt"
|
|
path.write_text(f"source {index}\n", encoding="utf-8")
|
|
sources.append(path)
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--draft",
|
|
draft,
|
|
"--source",
|
|
*sources,
|
|
"--kind",
|
|
"explanation",
|
|
"--route",
|
|
"auto",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
run_dir = Path(result.stdout.split("\t", 1)[0])
|
|
assert read_json(run_dir / "00_run.json")["route_hint"] == "deep"
|
|
|
|
|
|
def test_auto_route_counts_setext_headings_with_the_lint_parser(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
draft = tmp_path / "draft.md"
|
|
brief.write_text("제목이 많은 기존 문서를 다듬어 주세요.\n", encoding="utf-8")
|
|
sections = ["문서\n====\n"]
|
|
sections.extend(
|
|
f"절 {index}\n----\n짧은 설명입니다.\n" for index in range(1, 25)
|
|
)
|
|
draft.write_text("\n".join(sections), encoding="utf-8")
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--draft",
|
|
draft,
|
|
"--kind",
|
|
"explanation",
|
|
"--route",
|
|
"auto",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
run_dir = Path(result.stdout.split("\t", 1)[0])
|
|
manifest = read_json(run_dir / "00_run.json")
|
|
assert manifest["route_metrics"]["total_headings"] == 25
|
|
assert manifest["route_hint"] == "deep"
|
|
|
|
|
|
def test_missing_input_is_exit_two_and_does_not_publish_run(tmp_path: Path) -> None:
|
|
workspace = tmp_path / "runs"
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
tmp_path / "missing.md",
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
workspace,
|
|
)
|
|
assert result.returncode == 2
|
|
assert not workspace.exists()
|
|
|
|
|
|
def test_review_requires_draft(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("검토해 주세요.\n", encoding="utf-8")
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--mode",
|
|
"review",
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
)
|
|
assert result.returncode == 2
|
|
|
|
|
|
def test_review_initializes_exact_immutable_draft_target(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
draft = tmp_path / "draft.md"
|
|
brief.write_text("바이트를 바꾸지 말고 검토해 주세요.\n", encoding="utf-8")
|
|
draft_bytes = "# 원본\r\n\r\n검토 대상 `CacheKey`입니다.\r\n".encode("utf-8")
|
|
draft.write_bytes(draft_bytes)
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--draft",
|
|
draft,
|
|
"--mode",
|
|
"review",
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
"--date",
|
|
"2026-07-23",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
run_dir = Path(result.stdout.split("\t", 1)[0])
|
|
assert (run_dir / "07_draft.md").read_bytes() == draft_bytes
|
|
assert read_json(run_dir / "00_run.json")["inputs"]["draft_sha256"] == hashlib.sha256(
|
|
draft_bytes
|
|
).hexdigest()
|
|
|
|
|
|
def test_invalid_calendar_date_is_rejected(tmp_path: Path) -> None:
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("요청\n", encoding="utf-8")
|
|
result = run_cli(
|
|
"init_run.py",
|
|
"--brief",
|
|
brief,
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
tmp_path / "runs",
|
|
"--date",
|
|
"2026-02-30",
|
|
)
|
|
assert result.returncode == 2
|
|
|
|
|
|
def test_stage_creation_failure_releases_sequence_reservation(tmp_path: Path, monkeypatch) -> None:
|
|
canonical = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "skills"
|
|
/ "technical-doc-flow"
|
|
/ "scripts"
|
|
/ "init_run.py"
|
|
)
|
|
sys.path.insert(0, str(canonical.parent))
|
|
spec = importlib.util.spec_from_file_location("canonical_init_run", canonical)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("요청\n", encoding="utf-8")
|
|
workspace = tmp_path / "runs"
|
|
args = module.parser().parse_args(
|
|
[
|
|
"--brief",
|
|
str(brief),
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
str(workspace),
|
|
"--date",
|
|
"2026-07-23",
|
|
]
|
|
)
|
|
|
|
def fail_mkdtemp(*_args, **_kwargs):
|
|
raise OSError("simulated stage failure")
|
|
|
|
monkeypatch.setattr(module.tempfile, "mkdtemp", fail_mkdtemp)
|
|
try:
|
|
module.create_run(args)
|
|
except OSError:
|
|
pass
|
|
else:
|
|
raise AssertionError("stage failure must propagate")
|
|
assert not list(workspace.glob("*.reserve"))
|
|
|
|
|
|
def test_publish_race_preserves_empty_target_and_retries(tmp_path: Path, monkeypatch) -> None:
|
|
canonical = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "skills"
|
|
/ "technical-doc-flow"
|
|
/ "scripts"
|
|
/ "init_run.py"
|
|
)
|
|
sys.path.insert(0, str(canonical.parent))
|
|
spec = importlib.util.spec_from_file_location("canonical_init_run_publish_race", canonical)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
brief = tmp_path / "brief.md"
|
|
brief.write_text("요청\n", encoding="utf-8")
|
|
workspace = tmp_path / "runs"
|
|
args = module.parser().parse_args(
|
|
[
|
|
"--brief",
|
|
str(brief),
|
|
"--kind",
|
|
"explanation",
|
|
"--workspace",
|
|
str(workspace),
|
|
"--date",
|
|
"2026-07-23",
|
|
]
|
|
)
|
|
original_publish = module.publish_directory_noreplace
|
|
raced_target = workspace / "2026-07-23-001"
|
|
raced_inode: int | None = None
|
|
|
|
def create_target_during_publish(source: Path, destination: Path) -> None:
|
|
nonlocal raced_inode
|
|
if destination == raced_target:
|
|
destination.mkdir()
|
|
raced_inode = destination.stat().st_ino
|
|
original_publish(source, destination)
|
|
|
|
monkeypatch.setattr(module, "publish_directory_noreplace", create_target_during_publish)
|
|
run_dir, _, _ = module.create_run(args)
|
|
|
|
assert run_dir.name == "2026-07-23-002"
|
|
assert raced_inode is not None
|
|
assert raced_target.is_dir()
|
|
assert raced_target.stat().st_ino == raced_inode
|
|
assert list(raced_target.iterdir()) == []
|
|
assert not list(workspace.glob(".*.reserve"))
|
|
assert not list(workspace.glob(".2026-07-23-001-*"))
|