From cd4eabbf0e0489e18c7ee5fd8f17c73b02d10ca3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:07:41 +0900 Subject: [PATCH] =?UTF-8?q?test(b09-print):=20=EC=88=98=EB=9F=89=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=98=81=EC=97=AD=20=EC=9D=B4=EC=9D=8C=EB=A7=A4=20?= =?UTF-8?q?=E2=80=94=20302-3=20=EC=AA=BD=20=E2=86=92=20=EC=9D=B8=EC=87=84?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=84=9C=EB=B2=84=20=EB=AA=B8?= =?UTF-8?q?=20=E2=86=92=20=ED=91=9C=20PDF(=EC=A7=80=EB=8F=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC)=20=C2=B7=20=EC=97=91=EC=85=80=20=EC=B0=A8=EB=A1=80?= =?UTF-8?q?=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../print_frame/print_quantity_entry.ts | 8 ++ .../print_frame/print_quantity_seam.mjs | 110 ++++++++++++++++++ resources/tester/test_302_1_print_frame.py | 54 +++++++++ 3 files changed, 172 insertions(+) create mode 100644 resources/tester/print_frame/print_quantity_entry.ts create mode 100644 resources/tester/print_frame/print_quantity_seam.mjs diff --git a/resources/tester/print_frame/print_quantity_entry.ts b/resources/tester/print_frame/print_quantity_entry.ts new file mode 100644 index 000000000..de4ed8c16 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_entry.ts @@ -0,0 +1,8 @@ +/* ============================================================================= + * print_quantity_entry.ts (PLAN 302-1 시험 진입) + * 수량산출 영역(`collectQuantitySheets`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`). + * `print_quantity_seam.mjs` 가 vite 로 묶어 씀. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table"; diff --git a/resources/tester/print_frame/print_quantity_seam.mjs b/resources/tester/print_frame/print_quantity_seam.mjs new file mode 100644 index 000000000..f9f821be4 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_seam.mjs @@ -0,0 +1,110 @@ +// 302-1 수량산출 이음매 — 영역 모으기 → 인쇄 페이지 서버 몸(pdfBody · xlsxBody) 을 node 로. +// node print_quantity_seam.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64 } (302-3 시험 자료 꼴) +// 표준출력 끝 줄 = { pdf: {title, sheets}, xlsx: {title, sheets} } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-1-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/print_frame/print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const sheets = await mod.collectQuantitySheets(input.project); + console.log( + JSON.stringify({ + pdf: mod.pdfBody("수량산출", sheets), + xlsx: mod.xlsxBody("수량산출", sheets), + }), + ); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index a5c4fe0ff..a4d81b557 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -337,3 +337,57 @@ def test_앱_전체_길_목록에_겹친_앞머리_없음(): 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"]) + ) + + +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 # 거리이정표 지도 그림이 들어감