Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
209 lines
11 KiB
Python
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)
|