test(b09-print): 수량산출 영역 이음매 — 302-3 쪽 → 인쇄 페이지 서버 몸 → 표 PDF(지도 그림) · 엑셀 차례 (302-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-13 08:07:41 +09:00
co-authored by Claude Opus 5.5
parent 61e9adf369
commit cd4eabbf0e
3 changed files with 172 additions and 0 deletions
@@ -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";
@@ -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 });
}
@@ -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 # 거리이정표 지도 그림이 들어감