"""wiki_out — OKF 출력·사람 교정 보존 검증 (계획서 v4 §5.7, §11.10, docs/logic-chunk-design.md). 핵심 합격 기준: 사람 검토(verified: human:) 문서의 본문이 배치 재실행에 덮어써지면 실패. 로직 조각은 프로그램 문서 안의 `## 로직 조각` 섹션으로 들어간다 (unit 개별 문서 없음). """ from __future__ import annotations import json import re import sqlite3 import pytest from index.db import SCHEMA from wiki_out import merge from wiki_out.okf_writer import write_program_wiki from wiki_out.validate import validate UNIT_ID = "ZTEST1#ZTEST1_F01#FORM#SELECT_GL_LIST" @pytest.fixture() def con(): con = sqlite3.connect(":memory:") con.row_factory = sqlite3.Row con.executescript(SCHEMA) con.execute("INSERT INTO package VALUES('ZFI01','재무회계 공통',NULL)") prog_summary = json.dumps({"program": "ZTEST1", "business_purpose_ko": "총계정원장 잔액을 조회해 월별로 집계한다", "main_flow": ["조회", "집계", "ALV 출력"], "business_tags": ["총계정원장"], "sap_module": "FI-GL", "prompt_version": 2}, ensure_ascii=False) con.execute( "INSERT INTO program(name, devclass, title_ko, changed_on, source_hash, summary_json, summary_status, has_source) " "VALUES('ZTEST1','ZFI01','총계정원장 조회','2023-01-30','hash-v1',?,'done',1)", (prog_summary,)) # 조각 섹션은 소스 원문을 함께 실어야 한다 → include 코드가 있어야 검증된다. # L130-149 가 조각 범위이므로 그 구간에 알아볼 수 있는 코드를 둔다. code_lines = [f"* filler {i}" for i in range(1, 130)] + [ " SELECT racct SUM( hsl ) AS hsl", # 130 " INTO TABLE gt_acdoct", # 131 " FROM acdoct", # 132 " WHERE rbukrs EQ p_bukrs", # 133 " AND gjahr EQ p_gjahr.", # 134 ] + [f" WRITE {i}." for i in range(135, 150)] con.execute("INSERT INTO include(program, include, code_hash, line_count, code) VALUES(?,?,?,?,?)", ("ZTEST1", "ZTEST1_F01", "ihash", len(code_lines), "\n".join(code_lines))) con.execute("INSERT INTO topo VALUES('ZTEST1',?,0)", (UNIT_ID,)) refs = json.dumps({"tables_read": ["ACDOCT", "SKAT"], "tables_write": [], "calls": [{"target": "FORM GET_DATE_PREVIOUS_YEAR"}]}) thin = json.dumps({"purpose_ko": "ACDOCT 를 조회해 gt_acdoct 에 적재", "chunk_count": 1, "covered_lines": 20, "coverage": 0.29}, ensure_ascii=False) con.execute( "INSERT INTO unit(unit_id, program, include, unit_type, name, line_start, line_end, " "code_hash, signature, refs_json, summary_json, summary_status, prompt_version, loc, chunk_count) " f"VALUES('{UNIT_ID}','ZTEST1','ZTEST1_F01','FORM','SELECT_GL_LIST'," "120,188,'uhash-v1','',?,?,'done',2,69,1)", (refs, thin)) con.execute( "INSERT INTO logic_chunk(chunk_id, program, include, unit_id, seq, line_start, line_end, code_hash, kind, " "purpose_ko, purpose_en, keywords_ko, keywords_en, sap_objects, tables_read, tables_write, calls, " "confidence, prompt_version, extracted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (f"{UNIT_ID}#C1", "ZTEST1", "ZTEST1_F01", UNIT_ID, 1, 130, 149, "chash", "sql_select", "ACDOCT 에서 계정별 기말잔액을 조회한다", "Read period-end balances from ACDOCT", '["기말잔액", "총계정원장"]', '["G/L balance"]', '["ACDOCT"]', '["ACDOCT"]', "[]", "[]", 0.85, 2, "2026-09-16T00:00:00+00:00")) yield con con.close() def test_write_program_wiki(con, tmp_path): stats = write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) assert stats == {"chunks": 1, "programs": 1, "packages": 1} assert not (tmp_path / "units").exists() # unit 개별 문서는 만들지 않는다 prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8") assert "type: abap-program" in prog assert "status: stable" in prog # 프로그램 요약 있음 assert "x-chunk-count: 1" in prog assert "총계정원장 잔액을 조회해 월별로 집계한다" in prog assert "## 로직 조각 (1건 / unit 1개)" in prog assert "#### [sql_select] ACDOCT 에서 계정별 기말잔액을 조회한다" in prog assert "`abap://ZTEST1/ZTEST1_F01#L130-L149`" in prog # 조각 resource # --- 핵심: 조각은 소스 원문 + 자연어 설명이 한 쌍이어야 한다 --- assert "```abap" in prog, "조각에 코드펜스가 없다" assert re.search(r"^\s*130\|.*SELECT racct SUM\( hsl \)", prog, re.M), \ "조각 시작 줄의 소스 원문(줄번호 포함)이 없다" assert re.search(r"^\s*134\|.*AND gjahr\s+EQ p_gjahr\.", prog, re.M), \ "조각 마지막 줄까지 실려야 한다" assert re.search(r"^\s*149\|", prog, re.M), "조각 line_end(149)까지 실려야 한다" assert "* filler 129" not in prog, "조각 범위 밖의 코드가 새어 들어갔다" code_at = prog.index("```abap") desc_at = prog.index("Read period-end balances from ACDOCT") assert code_at < desc_at, "코드가 설명보다 먼저 와야 한다 (읽고 나서 설명을 대조)" assert "read [ACDOCT](/tables/ACDOCT.md)" in prog # 파서 사실 + 테이블 문서 링크 assert "이 unit 의 조각 1개가 20/69줄(29%)을 덮는다" in prog # 커버리지 안내 assert "| SELECT_GL_LIST | FORM |" in prog # unit 컨테이너 표 assert '"기말잔액"' in prog.split("---")[1] # 조각 키워드가 tags 로 manifest = (tmp_path / "index.md").read_text(encoding="utf-8") assert 'okf_version: "0.2"' in manifest assert "로직 조각 1건" in manifest assert validate(tmp_path) == [] # OKF conformance: 모든 문서에 type def test_chunk_links_to_declaration(con, tmp_path): """조각의 정의부 이름 → 아래 `## 정의부` 항목으로 가는 문서 내 링크가 걸려야 한다. 뷰어에서 눌러 이동하는 UX 다. 링크와 앵커가 어긋나면 조용히 죽으므로 여기서 짝을 검사한다. """ con.execute( "INSERT INTO declaration(program, name, kind, scope, unit_id, include, line_start, " "line_end, code, type_text, depends_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ("ZTEST1", "GT_ACDOCT", "data", "global", None, "ZTEST1_TOP", 10, 10, "DATA gt_acdoct TYPE TABLE OF acdoct.", "TYPE TABLE OF acdoct", "[]")) write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8") assert "## 정의부 (1건)" in prog assert "[`GT_ACDOCT`](#decl-ztest1_top-10-gt_acdoct)" in prog # 조각 쪽 링크 assert "{#decl-ztest1_top-10-gt_acdoct}" in prog # 정의부 쪽 앵커 assert "DATA gt_acdoct TYPE TABLE OF acdoct." in prog # 원문은 한 번만 assert prog.count("DATA gt_acdoct TYPE TABLE OF acdoct.") == 1 links = set(re.findall(r"\]\(#(decl-[^)]+)\)", prog)) anchors = set(re.findall(r"\{#(decl-[^}]+)\}", prog)) assert links and not (links - anchors), f"갈 곳 없는 링크: {links - anchors}" def test_program_without_chunks_is_draft(con, tmp_path): con.execute("DELETE FROM logic_chunk") con.execute("UPDATE program SET summary_json=NULL, summary_status='none'") write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) prog = (tmp_path / "programs" / "ZTEST1.md").read_text(encoding="utf-8") assert "status: draft" in prog assert "아직 추출된 로직 조각이 없다" in prog assert 'by: "abap-indexing/parser"' in prog def _human_edit(path): text = path.read_text(encoding="utf-8") fm, body = merge.split_frontmatter(text) fm += '\nverified: { by: "human:tester", at: "2026-08-26T00:00:00Z" }' human_line = "사람이 고친 설명: 이 프로그램은 결산용이다." path.write_text(f"---\n{fm}\n---\n{human_line}\n{body}", encoding="utf-8") return human_line def test_human_verified_body_preserved(con, tmp_path): """사람 검토 문서 본문 보존 — 덮어써지면 실패 (계획서 §11.10).""" write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) path = tmp_path / "programs" / "ZTEST1.md" human_line = _human_edit(path) write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) # 소스 변경 없이 재실행 after = path.read_text(encoding="utf-8") assert human_line in after assert merge.AUTO_SECTION not in after assert 'verified: { by: "human:tester"' in after def test_code_change_appends_draft(con, tmp_path): """사람 검토 후 코드 변경 → status: draft + 자동 생성 섹션 + _review.md.""" write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) path = tmp_path / "programs" / "ZTEST1.md" human_line = _human_edit(path) con.execute("UPDATE program SET source_hash='hash-v2' WHERE name='ZTEST1'") write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) after = path.read_text(encoding="utf-8") fm_after, body_after = merge.split_frontmatter(after) assert human_line in body_after assert merge.AUTO_SECTION in body_after assert re.search(r"^status: draft$", fm_after, re.M) assert 'x-code-hash: "hash-v2"' in fm_after review = (tmp_path / "_review.md").read_text(encoding="utf-8") assert "programs/ZTEST1.md" in review def test_unverified_doc_overwritten(con, tmp_path): write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) path = tmp_path / "programs" / "ZTEST1.md" path.write_text("---\ntype: abap-program\n---\n임의 수정(verified 없음)\n", encoding="utf-8") write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) assert "임의 수정" not in path.read_text(encoding="utf-8") def test_wiki_endpoint(con, tmp_path, monkeypatch): """GET /wiki/{path} — wiki 루트 상대 규약 + 경로 탈출 차단 (계획서 v4 §7.2).""" from fastapi.testclient import TestClient from config.settings import settings from query.api import app write_program_wiki("ZTEST1", con=con, wiki_dir=tmp_path) monkeypatch.setattr(settings, "wiki_dir", tmp_path) client = TestClient(app) r = client.get("/wiki/programs/ZTEST1.md") assert r.status_code == 200 data = r.json() assert data["path"] == "programs/ZTEST1.md" assert data["human_verified"] is False assert "## 로직 조각" in data["content"] assert client.get("/wiki/programs/ZTEST1").status_code == 200 assert client.get("/wiki/programs/NOPE.md").status_code == 404 assert client.get("/wiki/..%2F..%2Fsecret.md").status_code in (400, 404)