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