Files
ABAP-Indexing/tests/test_wiki_out.py
T

209 lines
11 KiB
Python

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