From 56ebe76e7fb0be6426b4c1a43f8159046a106da8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:59:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0):=20.estx=20=EA=B3=A0=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A4=84=20=C2=B7=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=ED=91=9C=20=EA=B0=99=EC=9D=80=20=EA=B8=B8=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EC=9C=A8=20=3D=20=EC=97=91=EC=85=80=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=C2=B7=20=ED=98=B8=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20=3D=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=20=ED=9B=84=EB=B3=B4=20=EC=88=98=20(PLAN=207?= =?UTF-8?q?07-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길: 이름 끝 .estx = `B09_Estimation_Estx` read_bill · read_prices(같은 그릇) · 요율 = 400 「같은 공사 엑셀에서」 - 미리 보기 `호표` = 대가 · 산근 목록 수 + 내역 줄이 코드로 가리킨 수(구성 줄 귀속은 안 함) - 창: 파일 고르기 *.estx · [단가표도 같이](받은 patch 를 캐시에 함께) · 지난 파일 요율 실패 = 까닭 알림 뒤 다시 고름 - 시험 test_707_5 8(길 갈림 · 호표 수 · 깨진 파일 400) Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_Router_Import.py | 33 +++++++++++-- B09_Estimation/B09_Estimation_UI_Import.ts | 49 +++++++++++++++---- resources/tester/test_707_5_estx.py | 14 ++++++ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index b51db8407..e2868fde1 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -18,6 +18,9 @@ {file_key, name, sheets, counts, linked, missing, patch{labor_cover, machine_cover, materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · + .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · + 요율 = 400 「같은 공사 엑셀에서」 · + 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) 화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈. `B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -37,6 +40,7 @@ from pydantic import BaseModel, Field from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B09_Estimation import B09_Estimation_Bill_Import as importer from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Estx as estx from B09_Estimation import B09_Estimation_Import_Prices as import_prices from B09_Estimation import B09_Estimation_Import_Rates as import_rates from B09_Estimation import B09_Estimation_Prices as prices @@ -82,13 +86,28 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key +def _is_estx(name: str) -> bool: + return name.lower().endswith(".estx") + + def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return read_bill(name, data) + return (estx.read_bill if _is_estx(name) else read_bill)(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error +def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None: + """.estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`).""" + if not _is_estx(name): + return None + got = estx.read_hopyo(name, data) + codes = {(k, x["코드"]) for k, v in got.items() for x in v} + hits = [h for line in parsed["줄"] if (h := line.get("호표"))] + return {**{k: len(v) for k, v in got.items()}, + "가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip + + def _draft(raw: Any) -> dict[str, Any] | None: if raw in (None, ""): return None @@ -158,8 +177,10 @@ async def import_bill( if not file_key: raise HTTPException(status_code=400, detail="파일을 고름") path = _saved(root, file_key) + data = path.read_bytes() if file is None: - parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) + parsed = await asyncio.to_thread(_parse, path.name, data) + hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed) rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) return { @@ -167,6 +188,7 @@ async def import_bill( "파일": path.name, **importer.preview(parsed, rows), "이음": len(found), + "호표": hopyo, } @@ -234,6 +256,8 @@ async def import_rate_sheet( root = await _project_root(project_id) key, name, data = await _file(file, file_key, root) draft = _draft(user) + if _is_estx(name): + raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴") mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[ "estimate" ] @@ -262,8 +286,9 @@ async def import_price_sheets( draft = _draft(user) mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root) try: - read = await asyncio.to_thread(import_prices.read_prices, name, data) - except ExcelError as error: + reader = estx.read_prices if _is_estx(name) else import_prices.read_prices + read = await asyncio.to_thread(reader, name, data) + except (ExcelError, BillExcelError) as error: raise HTTPException(400, str(error)) from error keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부 b08 = await main._b08_for(project_id, [], True) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index cfe1070fe..24b46fcaa 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -14,6 +14,9 @@ * 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) → * 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본 * 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림 + * + * EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) · + * 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -80,11 +83,17 @@ export function createBillImport( const total = seen.합계 || {}; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, - `엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + `${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` : "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)", + ...(seen.호표 + ? [ + `호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`, + "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)", + ] + : []), ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", @@ -106,6 +115,11 @@ export function createBillImport( const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, { disabled: !seen.남는자동, }); + const withPrices = seen.호표 + ? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", { + checked: true, + }) + : null; const run = createButton({ label: "불러오기", variant: "filled" }); run.dataset.b09ImportRun = ""; openModal({ @@ -117,6 +131,7 @@ export function createBillImport( ...lines.map((t) => createText(t, "muted", 14)), el("div", { className: "b09-import__choices", children: [take.root, price.root] }), el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }), + ...(withPrices ? [withPrices.root] : []), ); actions.append(run); run.addEventListener("click", async () => { @@ -132,11 +147,21 @@ export function createBillImport( showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + let made = merged(draft, got.user); + let tail = ""; + if (withPrices?.input.checked) { + const sheet = await postSheet("prices", made, null); + made = { ...made, ...sheet.patch }; + const c: Row = sheet.counts || {}; + tail = ` · 단가표 이음 ${Object.entries(PART) + .map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`) + .join(" · ")}`; + } // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); - done(got.payload, merged(draft, got.user)); + done(got.payload, made); const n = got.알림 || {}; - const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; + const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 @@ -167,7 +192,7 @@ export function createBillImport( label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => - pickFiles({ accept: ".xls,.xlsx" }, ([file]) => { + pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); @@ -206,11 +231,11 @@ const PART: Record = { labor: "노임", material: "자재", mach /** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ let last: { key: string; name: string } | null = null; -async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise { +async function postSheet(kind: Kind, user: Row, file: File | null): Promise { const form = new FormData(); if (file) form.append("file", file); else if (last) form.append("file_key", last.key); - form.append("user", JSON.stringify(ctx.user)); + form.append("user", JSON.stringify(user)); const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`; const response = await fetch(url, { method: "POST", body: form, credentials: "include" }); const body = (await response.json().catch(() => null)) as Row | null; @@ -332,8 +357,9 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri function pick(kind: Kind, ctx: TabContext): void { pickFiles( - { accept: ".xlsx,.xls" }, - ([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail), + { accept: ".xlsx,.xls,.estx" }, + ([file]) => + void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail), ); } @@ -347,9 +373,12 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { size: "sm", onClick: () => last - ? void postSheet(kind, ctx, null).then( + ? void postSheet(kind, ctx.user, null).then( (got) => openResult(kind, ctx, got), - () => pick(kind, ctx), + (error: unknown) => { + fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름 + pick(kind, ctx); + }, ) : pick(kind, ctx), }); diff --git a/resources/tester/test_707_5_estx.py b/resources/tester/test_707_5_estx.py index 887c2101c..0c9410248 100644 --- a/resources/tester/test_707_5_estx.py +++ b/resources/tester/test_707_5_estx.py @@ -125,3 +125,17 @@ def test_깨진_파일_표_없음_막힘(): z.writestr("Other.dat", b"") with pytest.raises(ex.EstxError, match="EstMainDat"): ex.read_bill("a.estx", buf.getvalue()) + + +def test_불러오기_길_estx_갈림(data): + """707-5 고리 — 이름 끝 .estx = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보).""" + from B09_Estimation import B09_Estimation_Router_Import as route + + name = "거창.ESTX" + parsed = route._parse(name, data["거창"]) + assert parsed["꼴"] == "estx" and parsed["합계"]["직접"] == 384916227 + assert route._hopyo(name, data["거창"], parsed) == {"대가": 14, "산근": 61, "가리킴": 48} + assert route._hopyo("a.xlsx", data["거창"], parsed) is None + with pytest.raises(route.HTTPException) as got: + route._parse("a.estx", b"not a zip") + assert got.value.status_code == 400 From 1bb3b43064dd0ee4ba17e524d9f84e951e4a8bc9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:07:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(=EB=82=A1=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98):=20=ED=95=A9=EC=B9=9C=20=ED=8C=90=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=84=B7=20=E2=80=94=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=9D=A0=20=EC=95=84=EB=8B=98=20=C2=B7=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EB=92=A4=20=EC=9D=BC=EA=B0=90=EC=9D=B4=20=EB=B0=94=EA=BE=BC=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20(PLAN=20707-5=20=EB=94=B8=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_224_1 · helper_280_1: 고르기 창 몸 = 위 표 · 규격 표(704-4 specHost) · 열 — 둘째 자리 → 셋째 자리 - test_280_1 · test_b05_structures_form_summary 도우미: 부품 상자 글꼴 import(ui_template_icons.css)를 .ts 로 찾던 흠 — .css = 빈 모듈 - test_m02_precombo_scope: precombo 배선에 706-1 `more` 덧붙음 — 한 줄 글 대조 → scope · bar 줄 대조 - test_structures_summary_snapshot: robocopy 가 복제 도구 본체(common_util_project_copy)로 옮겨 감 — 껍데기 대신 본체 읽음 - test_202_1 · test_704_4: 합친 판에서 통과 — 고칠 것 없음 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_280_1_combo_pick.cjs | 3 ++- resources/tester/helper_b05_structures_form_summary.cjs | 1 + resources/tester/test_224_1_picker_top_table.py | 2 +- resources/tester/test_m02_precombo_scope.py | 2 +- resources/tester/test_structures_summary_snapshot.py | 4 +++- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/resources/tester/helper_280_1_combo_pick.cjs b/resources/tester/helper_280_1_combo_pick.cjs index 9b4ffd3f9..d0669661c 100644 --- a/resources/tester/helper_280_1_combo_pick.cjs +++ b/resources/tester/helper_280_1_combo_pick.cjs @@ -147,6 +147,7 @@ const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (request in STUBS) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); return require(request); }, @@ -178,7 +179,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); const out = {}; void openItemPicker({ current: null, canCopy: false }); await flush(); - const [, cols] = modal.body.children; + const [, , cols] = modal.body.children; // 위 표 · 규격 표(704-4) · 열 const [search, layerBar, treeHost] = cols.children[0].children; const searchInput = search.children[0]; const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 4c7ff7c35..62fd8d5b6 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -119,6 +119,7 @@ const { loadTs } = makeLoader({ requireFor: (full) => (request) => { if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) — 트랜스파일 못 함 // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); // 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음 diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py index e9258b32c..c9a5cae3a 100644 --- a/resources/tester/test_224_1_picker_top_table.py +++ b/resources/tester/test_224_1_picker_top_table.py @@ -117,7 +117,7 @@ def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): ts = _text(UI / "ui_template_item_picker.ts") assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무 assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2) - assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 + assert "body.append(tableWrap, specHost, cols, editHost," in ts # 맨 위(704-4 규격 표 뒤) assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 assert "comboTable.destroy();" in ts # 닫을 때 표 거둠 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 77a4ce3db..fe9bd01fa 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -77,7 +77,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 assert page.count('layerBar("') == 1 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - assert "precombo: { scope: () => pre, bar: preBar }," in page + assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음 def test_고름에_owner_권한_서고_길에_명시_owner(): diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py index 2a3910762..8a0c0ac54 100644 --- a/resources/tester/test_structures_summary_snapshot.py +++ b/resources/tester/test_structures_summary_snapshot.py @@ -300,5 +300,7 @@ def test_스냅샷이_없으면_알림(world): def test_초기화는_templates_를_안_건드리고_복제는_통째로(): assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) - tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") + tool = (config_system.PROJECT_ROOT / "common_util/common_util_project_copy.py").read_text( + "utf-8" + ) # 복제 도구 본체(tools_clone_project = CLI 껍데기) assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함) From a8cf79cee002572691b2b01b8f04fc9258578e4d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:14:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(=EA=B3=B5=EC=9A=A9=20=ED=91=9C=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4):=20=EC=B0=BD=20=EB=81=9D=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=20=EC=85=88(spaceBelow)=EC=9D=B4=20=EC=97=B4=EB=A6=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EA=B0=80=EB=A6=BC=EB=A7=89=20=C2=B7=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC(fixed=20=C2=B7=20absolute)=EC=9D=84=20=ED=91=9C=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0=20(PLAN=20707-5=20=EB=94=B8=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - body 끝 가림막(창 높이)이 뒤 형제로 잡혀 창이 열린 채 다시 그리면 표가 최소 120px 로 짜부 - 흐름 밖(position fixed · absolute) 형제 = 자리 안 씀 - 부품 모음 견본(화면 끝 표 · 견본 창): 전 794 → 120px · 뒤 794 → 794px(밝음 · 어두움) - 시험 test_202_1 가림막 줄 덧붙임 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_202_1_fill_below.cjs | 25 ++++++++++++------- .../tester/test_202_1_table_hint_bottom.py | 1 + ui_template/ui_template_data_table.ts | 3 ++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/resources/tester/helper_202_1_fill_below.cjs b/resources/tester/helper_202_1_fill_below.cjs index 73fd59feb..190b7517c 100644 --- a/resources/tester/helper_202_1_fill_below.cjs +++ b/resources/tester/helper_202_1_fill_below.cjs @@ -44,17 +44,19 @@ const sty = (n) => ({ paddingBottom: n.css.paddingBottom ?? "0px", borderBottomWidth: n.css.borderBottomWidth ?? "0px", marginBottom: n.css.marginBottom ?? "0px", + position: n.css.position ?? "static", }); const box = { exports: {} }; -new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)( - box.exports, - box, - (p) => (p.endsWith(".css") ? {} : require(p)), - win, - { body: null }, - sty, - RO, -); +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); const m = box.exports; // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40] @@ -81,4 +83,9 @@ hint.height = 60; observers.forEach((o) => o.fn()); out.afterHintGrow = parseFloat(root.style.maxHeight); out.firstMinusGrow = out.first - 20; + +// 열린 창 가림막(body 끝 · position fixed · 창 높이) = 아래 자리에 안 셈(707-5) +link(body, card, el("backdrop", { position: "fixed" }, 900)); +out.belowWithModal = m.spaceBelow(root); +out.belowAfterGrow = 3 * 8 + 20 + 30 + 60 + 12 + 8; console.log(JSON.stringify(out)); diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 5d89460f6..f79393036 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -177,3 +177,4 @@ def test_창_끝까지_셈에_아래_안내_줄_포함(): assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백 assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦 + assert r["belowWithModal"] == r["belowAfterGrow"] # 열린 창 가림막(fixed) = 아래 자리 아님 diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 6fd8c5c2d..7ffe3a975 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -410,7 +410,8 @@ export function spaceBelow(el: HTMLElement): number { for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { const h = s.getBoundingClientRect().height; const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top) - if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom); + const out = m.position === "fixed" || m.position === "absolute"; // 열린 창 가림막 · 알림 = 자리 안 씀 + if (h && !out) sum += h + gap + px(m.marginTop) + px(m.marginBottom); } sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); }