Files
Aislo/resources/tester/test_302_1_print_frame.py
T

443 lines
20 KiB
Python

"""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: "되메우기 <A&B>", 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 "되메우기 <A&B>" 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"<tr><td>줄{i}</td><td data-num>{i}</td></tr>" for i in range(120))
html = (
'<table class="ui-data-table__table" style="--dt-w:3000px"><colgroup>'
'<col style="width:1500px"><col style="width:1500px"></colgroup>'
f"<thead><tr><th>이름</th><th>값</th></tr></thead><tbody>{rows}</tbody></table>"
)
doc = pr.document_html("t", [{"name": "넓은 표", "orientation": "portrait", "html": html}])
try:
data = pr.spawn_pdf(doc)
except pr.PrintError as error:
pytest.skip(str(error))
pdf = pymupdf.open(stream=data)
words = [w for p in pdf for w in p.get_text("words")]
assert max(w[2] for w in words) <= pdf[0].rect.width # 오른쪽이 쪽 밖으로 안 나감
texts = [p.get_text() for p in pdf]
assert "줄119" in texts[-1]
assert all("넓은 표" in t and "이름" in t for t in texts) # 쪽마다 머리
def test_table_pdf_바깥_요청_막음():
html = '<table><thead></thead></table><img src="http://127.0.0.1:9/x.png">'
doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}])
assert "<script" not in pr.PRINT_CSS
try:
assert pr.spawn_pdf(doc)[:5] == b"%PDF-" # 막힌 그림 = 빈 칸 · PDF 는 나옴
except pr.PrintError as error:
pytest.skip(str(error))
def _assert_print(wb, orientations: list[str], heads: list[int]) -> None:
"""엑셀 인쇄 설정(302-5) — 시트 방향 = PDF · A4 · 폭 1쪽 · 높이 자유 · 머리 줄 반복."""
assert len(wb.worksheets) == len(orientations)
for ws, orient, head in zip(wb.worksheets, orientations, heads):
ps = ws.page_setup
assert ps.orientation == orient, ws.title
assert int(ps.paperSize) == 9 # A4
assert (int(ps.fitToWidth), int(ps.fitToHeight)) == (1, 0), ws.title
assert ws.sheet_properties.pageSetUpPr.fitToPage is True
assert ws.print_title_rows.replace("$", "") == f"1:{head}", ws.title
assert ws.page_margins.left < 0.5 and ws.page_margins.bottom < 0.6
def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸():
from openpyxl import load_workbook
sheets = _sheets()
keys = ("name", "orientation", "grid")
body = {"title": "수량", "sheets": [{k: s[k] for k in keys} for s in sheets]}
body["sheets"].append({"name": "갑 _ 수량_1_", "grid": sheets[0]["grid"]}) # 같은 이름
res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body)
assert res.status_code == 200, res.text
wb = load_workbook(io.BytesIO(res.content))
_assert_print(wb, [*[s["orientation"] for s in sheets], "portrait"], [2, 2, 2])
names = wb.sheetnames
assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _
assert (
names[1] == "을_ 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만"[:31]
and len(names[1]) == 31
)
assert names[2] == "갑 _ 수량_1_ (2)"
ws = wb[names[0]]
merged = {str(m) for m in ws.merged_cells.ranges}
# 머리 두 줄 — 「물량」 = 옆 둘 · 공종 · 이름 = 아래 둘 / 본문 공종 「토공」 세 줄 세로 병합
assert {"C1:D1", "A1:A2", "B1:B2", "A3:A5"} <= merged
assert (ws["C1"].value, ws["C2"].value, ws["A1"].value) == ("물량", "수량", "공종")
assert ws["A1"].font.b and ws["C2"].font.b # 머리 굵게
assert (
ws["C4"].value == 12.5
and ws["C4"].data_type == "n"
and ws["C4"].number_format == "#,##0.00"
)
assert ws["D6"].value == 2000000 and ws["D6"].number_format == "#,##0"
assert ws["B5"].value == "되메우기 <A&B>" and ws["C3"].value is None
assert ws["B4"].alignment.indent == 1 and ws["B3"].font.b # 레벨 2 들여씀 · 제목 줄 굵게
assert (
ws["B7"].value == "합계" and ws["D7"].value == 2001800 and ws["D7"].font.b
) # 합계 = 끝 줄
assert ws["A3"].border.left.style == "thin"
assert ws.freeze_panes == "B3"
def test_table_xlsx_꼴_틀리면_400():
res = _client().post(
f"/api/projects/{PID}/print/table-xlsx", json={"sheets": [{"name": "a", "grid": {}}]}
)
assert res.status_code == 400
res = _client().post(f"/api/projects/{PID}/print/table-pdf", json={"sheets": []})
assert res.status_code == 422
def test_시트_이름_다듬기():
used: set[str] = set()
assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h"
assert pr.sheet_title("", used) == "시트"
assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름
# ── 영역 이음매 — 원가계산(302-4) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ────────────
ENTRY = ROOT / "resources/tester/print_frame/print_frame_entry.ts"
VITE = ROOT / "config/node_modules/vite/bin/vite.js"
@pytest.fixture(scope="module")
def cost_bodies(tmp_path_factory) -> dict:
sys.path.insert(0, str(ROOT / "resources/tester"))
import test_302_4_print_cost as t302_4
if shutil.which("node") is None:
pytest.skip("node 가 없음")
work = tmp_path_factory.mktemp("p302_1")
root = t302_4.t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")])
data = work / "payload.json"
data.write_text(json.dumps(t302_4._payload(root), ensure_ascii=False, default=str), "utf-8")
out = work / "build"
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel,
"--outDir", str(out)],
cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=180,
) # fmt: skip
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(out / "print_frame_entry.js"), str(data)],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
) # fmt: skip
assert run.stdout.strip(), run.stderr
return json.loads(run.stdout)
def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies):
from openpyxl import load_workbook
body = cost_bodies["xlsx"]
assert len(body["sheets"]) == 18
res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body)
assert res.status_code == 200, res.text
wb = load_workbook(io.BytesIO(res.content))
assert wb.sheetnames == [s["name"] for s in body["sheets"]] # 이름 31자 안 · 차례 그대로
first = wb[wb.sheetnames[0]]
assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감
assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None)
_assert_print( # 엑셀 인쇄 방향 = 그 시트 PDF 방향(302-5)
wb,
[s["orientation"] for s in cost_bodies["pdf"]["sheets"]],
[s["grid"]["head_rows"] for s in body["sheets"]],
)
def test_원가_영역_PDF_시트_이름_가로(cost_bodies):
pymupdf = pytest.importorskip("pymupdf")
body = cost_bodies["pdf"]
assert all(s["orientation"] == "landscape" and "<table" in s["html"] for s in body["sheets"])
res = _client().post(f"/api/projects/{PID}/print/table-pdf", json=body)
if res.status_code == 503:
pytest.skip(res.json()["detail"])
assert res.status_code == 200, res.text
doc = pymupdf.open(stream=res.content)
assert doc.page_count >= 18
text = "".join(p.get_text() for p in doc)
assert all(s["name"] in text for s in body["sheets"])
assert all(p.rect.width > p.rect.height for p in doc)
def test_앱_전체_길_목록에_겹친_앞머리_없음():
import main
paths = [getattr(r, "path", "") for r in main.app.routes]
assert not [p for p in paths if "/api/projects/api/projects" in p]
for tail in ("drawings", "drawings-pdf", "table-pdf", "table-xlsx"):
assert paths.count(f"/api/projects/{{project_id}}/print/{tail}") == 1, tail
# ── 영역 이음매 — 수량산출(302-3) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ────────────
#: 1×1 PNG — 거리이정표 지도 그림 자리
TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501
@pytest.fixture(scope="module")
def quantity_bodies() -> dict:
sys.path.insert(0, str(ROOT / "resources/tester"))
import test_302_3_print_quantity as t302_3
if shutil.which("node") is None:
pytest.skip("node 가 없음")
job = {"project": "p302", "responses": t302_3._responses(), "image_b64": TINY_PNG}
out = subprocess.run(
["node", str(ROOT / "resources/tester/print_frame/print_quantity_seam.mjs"), str(ROOT)],
input=json.dumps(job, ensure_ascii=False), cwd=ROOT, capture_output=True, text=True,
encoding="utf-8", timeout=300,
) # fmt: skip
assert out.returncode == 0, out.stderr
return json.loads(out.stdout.strip().splitlines()[-1])
def test_수량_영역_엑셀_차례_그대로(quantity_bodies):
from openpyxl import load_workbook
body = quantity_bodies["xlsx"]
assert len(body["sheets"]) >= 20
res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body)
assert res.status_code == 200, res.text
wb = load_workbook(io.BytesIO(res.content))
used: set[str] = set()
assert wb.sheetnames == [pr.sheet_title(s["name"], used) for s in body["sheets"]]
assert all(
wb[n].max_row >= s["grid"]["head_rows"] for n, s in zip(wb.sheetnames, body["sheets"])
)
_assert_print(
wb,
[s["orientation"] for s in quantity_bodies["pdf"]["sheets"]],
[s["grid"]["head_rows"] for s in body["sheets"]],
)
def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies):
pymupdf = pytest.importorskip("pymupdf")
body = quantity_bodies["pdf"]
assert any('src="data:image/png' in s["html"] for s in body["sheets"]) # 그림 = data: 주소
res = _client().post(f"/api/projects/{PID}/print/table-pdf", json=body)
if res.status_code == 503:
pytest.skip(res.json()["detail"])
assert res.status_code == 200, res.text
doc = pymupdf.open(stream=res.content)
assert doc.page_count >= len(body["sheets"])
text = "".join(p.get_text() for p in doc).replace("\n", " ")
missing = [s["name"] for s in body["sheets"] if s["name"] not in text]
assert not missing
assert sum(len(p.get_images()) for p in doc) >= 1 # 거리이정표 지도 그림이 들어감
def test_B09_좌측_하단_단추는_초안_입구에서만():
ui = _text(DRAFT / "B09_Draft_Print_UI.ts")
assert "DRAFT.actions.push(" in ui and "navigateTo(ROUTE.path" in ui
assert 'label: "인쇄 미리보기"' in ui
shell = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts")
assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell
dock = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Dock.ts")
assert "DRAFT.actions.map((make) => make())" in dock and "Print" not in dock
def test_좌측_하단_두_줄_가로_넘침_없음():
"""302-5 — 위 = [PDF][엑셀] · 아래 = [?](도움말 줄) [B09 로 돌아가기] · 줄 폭을 나눠 가짐."""
page = _text(DRAFT / "B09_Draft_Print_Page.ts")
assert "files.append(pdfBtn, xlsxBtn)" in page
assert "nav.dataset.helpRow" in page and "nav.append(back)" in page
assert "actions.append(files, nav)" in page
css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css"))
assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css
btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :]
btn = btn[: btn.index("}")]
# 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표
assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn