Files
document-haness/tests/test_init_run.py
T

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-*"))