"""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"줄{i}{i}" for i in range(120)) html = ( '' '' f"{rows}
이름값
" ) 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 = '
' doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}]) assert " 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 == "되메우기 " 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 "= 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