"""PLAN 302-1 — 인쇄 미리보기 틀(초안 길 등록 · 서버 표 PDF · 엑셀 · 표 → 서버 이음매)."""
import io
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Router as b09 # noqa: E402
from B09_Estimation.draft import B09_Draft_Print_Router as pr # noqa: E402
DRAFT = ROOT / "B09_Estimation/draft"
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
PID = "00000000-0000-0000-0000-000000000001"
def _text(path: Path) -> str:
return path.read_text("utf-8")
def _client() -> TestClient:
app = FastAPI()
app.include_router(pr.router, prefix="/api/projects")
return TestClient(app)
def _ts(expr: str) -> object:
"""표 짜기(공용 표 + 인쇄 표) TS 를 노드로 — expr 결과 JSON."""
js = "".join(
IMPORTS.sub("", _text(p))
for p in (
ROOT / "ui_template/ui_template_data_table.ts",
DRAFT / "B09_Draft_Print_Table.ts",
)
)
run = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=js + f"\nconsole.log(JSON.stringify({expr}));",
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
#: 머리 두 줄(묶음) · 숫자 열 · 레벨 · 세로 병합 · 합계 · 제목 줄
TABLE = """{
columns: [
{ key: "kind", label: "공종" },
{ key: "name", label: "이름", width: 120 },
{ key: "q", label: "수량", group: ["물량"], kind: "number", decimals: 2 },
{ key: "a", label: "금액", group: ["물량"], kind: "number" },
],
rows: [
{ kind: "토공", name: "토공", q: null, a: null },
{ kind: "토공", name: "터파기", q: 12.5, a: 1500 },
{ kind: "토공", name: "되메우기 ", q: 3, a: 300 },
{ kind: "포장", name: "포장", q: 1234.567, a: 2000000 },
],
total: { name: "합계", a: 2001800 },
frozen: 1,
mergeSame: ["kind"],
level: { key: "name", of: (i) => (i === 0 ? 1 : 2) },
rowClass: (i) => (i === 0 ? "is-title" : null),
onRowClick: () => {},
}"""
def _sheets():
return _ts(
f"(() => {{ const t = {TABLE}; return ["
"{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," # noqa: E501
" grid: sheetGrid(t)},"
"{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," # noqa: E501
" html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," # noqa: E501
" caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()"
)
# ── 길 등록 ──────────────────────────────────────────────────────────────
def test_초안_길은_선언으로_등록_정적_표엔_이름_없음():
router = _text(ROOT / "A00_Common/router.ts")
assert (
'import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })'
in router
)
assert "routeTable[route] ?? draft?.load" in router
assert "draft?.workflow" in router and "draft?.project" in router
assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts")
route = _text(DRAFT / "B09_Draft_Print_Route.ts")
assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route
# 가짜 대조 — 정적 표 · 셸 · 문구에 초안 이름이 없음(폴더째 지우면 길이 함께 사라짐)
for rel in (
"config/config_frontend.ts", "A00_Common/router.ts", "A00_Common/main.ts",
"ui_template/ui_template_loupe.ts", "B09_Estimation/B09_Estimation_UI_Shell.ts",
"B09_Estimation/B09_Estimation_UI_Tabs.ts",
): # fmt: skip
body = _text(ROOT / rel)
assert "b09-print" not in body and "Draft_Print" not in body, rel
# 돋보기 · 기본 메뉴 막음 = 주소 꼴(b0x-)로 함께 걸림
assert re.search(r"/\^\(b0\[1-9\]", _text(ROOT / "ui_template/ui_template_loupe.ts"))
def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monkeypatch):
paths = {r.path for r in b09.router.routes}
assert "/api/projects/{project_id}/print/table-pdf" in paths
assert "/api/projects/{project_id}/print/table-xlsx" in paths
# 가짜 폴더 — 비면 아무것도 안 끼움 · *_Router.py 가 있으면 그 길만 · 깨진 초안은 건너뜀
pkg = tmp_path / "fakepkg"
(pkg / "draft").mkdir(parents=True)
(pkg / "__init__.py").write_text("")
(pkg / "draft/__init__.py").write_text("")
monkeypatch.syspath_prepend(str(tmp_path))
empty = APIRouter()
assert b09.include_drafts(empty, pkg / "draft", "fakepkg.draft") == []
assert empty.routes == []
(pkg / "draft/X_Router.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/x')\ndef x():\n return 1\n"
)
(pkg / "draft/Y_Router.py").write_text("import 없는_모듈\n")
got = APIRouter()
assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"]
assert [r.path for r in got.routes] == ["/x"]
# 초안이 같은 머리를 스스로 달았으면 한 번만(도면 302-2 꼴)
(pkg / "draft/Z_Router.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter(prefix='/api/projects')\n"
"@router.get('/{project_id}/z')\ndef z():\n return 1\n"
)
head = APIRouter(prefix="/api/projects")
assert b09.include_drafts(head, pkg / "draft", "fakepkg.draft") == ["X_Router", "Z_Router"]
assert {r.path for r in head.routes} == {"/api/projects/x", "/api/projects/{project_id}/z"}
assert [r.path for r in sys.modules["fakepkg.draft.Z_Router"].router.routes] == [
"/api/projects/{project_id}/z"
] # 원본 그대로(앱에 바로 붙이는 시험)
assert (
"/api/projects/{project_id}/print/drawings-pdf" in paths
or not (DRAFT / "B09_Draft_Print_Drawings_Router.py").exists()
)
def test_로그인_회사_권한은_B09_길과_같음():
main = _text(ROOT / "main.py")
assert "app.include_router(b09_estimation_router, dependencies=protected_with_company)" in main
assert "Depends(require_project_access)" in main
# 초안 router 는 머리 · 의존성 없이 B09 router 안으로만 들어감
assert "APIRouter()" in _text(DRAFT / "B09_Draft_Print_Router.py")
# ── 서버 표 출력 ──────────────────────────────────────────────────────────
def test_table_pdf_쪽_방향_시트_이름_쪽_번호():
pymupdf = pytest.importorskip("pymupdf")
sheets = _sheets()
res = _client().post(
f"/api/projects/{PID}/print/table-pdf",
json={"title": "수량산출", "sheets": [{k: s[k] for k in ("name", "orientation", "html")}
for s in sheets]},
) # fmt: skip
if res.status_code == 503:
pytest.skip(res.json()["detail"]) # 서버에 Edge · Chrome 없음
assert res.status_code == 200, res.text
assert res.content[:5] == b"%PDF-" and res.headers["content-type"] == "application/pdf"
doc = pymupdf.open(stream=res.content)
assert doc.page_count == 2
p1, p2 = doc[0], doc[1]
assert p1.rect.width < p1.rect.height and p2.rect.width > p2.rect.height # 시트마다 방향
t1, t2 = p1.get_text(), p2.get_text()
assert "갑 / 수량[1]" in t1 and "을: 원가표" in t2 # 쪽 머리 = 시트 이름
assert "1 / 2" in t1 and "2 / 2" in t2 # 쪽 번호
assert "되메우기 " in t1 and "1,234.57" in t1 and "2,001,800" in t1 # 화면과 같은 글
assert "그림 <1>" in t2
def test_table_pdf_넓은_표는_쪽_폭에_맞춤_긴_표는_머리_되풀이():
pymupdf = pytest.importorskip("pymupdf")
rows = "".join(f" " for i in range(120))
html = (
'줄{i} {i}
| 이름 | 값 |
|---|
'
doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}])
assert "