diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 370c5c307..e228d23be 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -115,6 +115,13 @@ ${comboId(item)}`; return found; } +/** 글 폭 추정 — 한글 12px · 그 밖 7px + 여백 16(글 12px · 칸 여백 8 × 2). */ +export const textWidth = (text: string): number => + [...text].reduce((sum, ch) => sum + (/[ㄱ-ㆎ가-힣]/.test(ch) ? 12 : 7), 16); +/** 열 폭 — 맨 아래 단 글 · 칸 글 중 가장 긴 것 · 44~100(넘는 머리는 줄바꿈 · 120-2). */ +export const widthOf = (...texts: string[]): number => + Math.min(100, Math.max(44, ...texts.map(textWidth))); + /** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */ export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> { const spans: Array<[string, number]> = []; diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index a74e7d678..3ccc26bcc 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -18,3 +18,9 @@ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } + +/* 구조물 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 공용 표 CSS 는 그대로) */ +[data-b08-summary="table"] thead th { + white-space: normal; + overflow-wrap: anywhere; +} diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 3686f449c..799a4826d 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -5,8 +5,10 @@ * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` - * 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격 - * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로 + * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 + * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 + * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 + * 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2) * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * ========================================================================== */ @@ -31,6 +33,7 @@ import { type ComboLabel, type PlacedItem, type StationCombo, + widthOf, } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { @@ -50,9 +53,6 @@ function note(text: string): HTMLElement { return node; } -/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */ -const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24)); - /** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ interface PipeColumn { id: string; @@ -65,6 +65,8 @@ interface PipeSummary { rows: Array<{ chainage_m: number; values: Record }>; total: Record; notes: string[]; + /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ + types: string[]; } async function fetchPipeSummary(projectId: string): Promise { @@ -80,8 +82,6 @@ const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ const placesOf = (values: number[]): number => Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); -const longest = (texts: string[]): string => - texts.reduce((a, b) => (b.length > a.length ? b : a), ""); /** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { @@ -97,14 +97,21 @@ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | n ...summary.rows.map((row) => row.values[column.id]), summary.total[column.id], ].filter((value): value is number => typeof value === "number"); + const decimals = placesOf(values); + const texts = values.map((value) => + value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }), + ); return { key: column.id, label: column.label, group, kind: "number", - decimals: placesOf(values), + decimals, title: [...group, column.label].join(" › "), - width: widthOf(longest([...group.slice(1), column.label])), + width: widthOf(column.label, ...texts), }; }); } @@ -121,9 +128,11 @@ function buildTable( const total: DataRow | null = pipe?.columns.length ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } : null; + const labels = stations.map((station) => stationLabel(station, interval)); + const stationHead = L("B08_Quantity_Summary_Station"); const table = createDataTable({ columns: [ - { key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 }, + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) }, ...columns.map((column) => ({ key: column.id, label: column.name, @@ -135,12 +144,12 @@ function buildTable( ...(pipe ? pipeColumns(pipe, comboPath) : []), ], // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 - rows: stations.map((station) => ({ - station: stationLabel(station, interval), + rows: stations.map((station, i) => ({ + station: labels[i], ...values.get(stationKey(station)), })), total, - onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)), + onRowClick: (i) => onRowClick(stations[i], labels[i]), frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), @@ -198,13 +207,14 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin })); const things: PlacedItem[] = [...structures.structures, ...placed]; const stations = summaryStations(things); - // 배관은 서버 집계 열(98) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + const summary = typeof pipe === "string" ? null : pipe; + const served = new Set(summary?.types ?? []); const others = [ - ...structures.structures, - ...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"), + ...structures.structures.filter((item) => !served.has(item.type_id)), + ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; const columns = summaryColumns(others, describe); - const summary = typeof pipe === "string" ? null : pipe; const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); for (const text of summary?.notes ?? []) parts.push(note(text)); diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py new file mode 100644 index 000000000..ec364dfaf --- /dev/null +++ b/resources/tester/test_120_b08_drain_summary.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 「횡단배수」 관 밖 구조물 · 열 폭 — PLAN 120. + +견본: + 300 관 Φ800 L18 + BOX 2.0×2.0(같은 측점 · 키 겹침 막음) 본체 9.6 m · 유입 옛 「있음」 = 날개벽 · + 유출 흙막이 메 · 높이 빈칸 → 박스 높이 2.0 + 340 BOX 사용자 입력 2.5×1.5 · 본체 길이 없음(B06 [저장] 전) · 유출 날개벽 + 400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4 + 500 물넘이 포장 월류 폭 8 · H=1.0 + 600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합) +""" + +from __future__ import annotations + +import copy +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = Path(__file__).parent / "fixtures" +needs_node = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _item(key: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": key} + + +POINTS = [ + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}}, + { + "chainage_m": 300.0, + "facility": "box_culvert", + "options": { + "body_width_m": 2, + "body_height_m": 2, + "wing_in": "있음", + "wing_out": "흙막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + }, + "summary_item": _item("SS000101"), + "group_items": {"gboxbody": _item("SS000102")}, + }, + { + "chainage_m": 340.0, + "facility": "box_culvert", + "options": { + "body_width_m": 2.5, + "body_height_m": 1.5, + "wing_in": "없음", + "wing_out": "날개벽", + }, + }, + { + "chainage_m": 400.0, + "facility": "ford_bridge", + "options": { + "pipe_diameter_mm": 1000, + "pipe_count": 2, + "ford_width_m": 12, + "inlet_type": "날개벽", + "wing_in": "있음", + "outlet_type": "흙막이", + "wing_out": "없음", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_before_m": 3, + "outlet_revet_after_m": 4, + }, + "group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")}, + }, + { + "chainage_m": 500.0, + "facility": "ford_pavement", + "options": {"ford_width_m": 8, "ford_height_m": 1.0}, + }, +] +STRUCTURES = [ + {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600}, + { + "structure_id": "s-exposed", + "type_id": "cross_drain_exposed", + "placement": "point", + "chainage_m": 600, + }, + { + "structure_id": "s-ud", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": 700, + "group_items": {"gcrossud": _item("SS000105")}, + }, +] +DESIGNS = [ + {"chainage_m": 300.0, "design": {"pipe_length_m": 18, "box_length_m": 9.6, "inlet_basin": ""}}, + {"chainage_m": 340.0, "design": {}}, +] + + +@pytest.fixture +def out(tmp_path: Path) -> dict: + root = tmp_path / "proj" + edits = root / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + route = root / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + names = definition.read()["종류"]["pipe"]["이름"] + return summary.pipe_summary(root, DESIGNS, names, 6.0) + + +def _cell(out: dict, at: float, cid: str): + row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01) + return row["values"].get(cid) + + +def _col(out: dict, cid: str) -> dict: + return next(c for c in out["columns"] if c["id"] == cid) + + +def test_같은_측점_관_박스_따로(out): + assert _cell(out, 300, "pipe:len|800") == 18 + assert _cell(out, 300, "pipe:box|body|2.0×2.0") == 9.6 + + +def test_박스_본체_날개벽_흙막이(out): + body = _col(out, "pipe:box|body|2.0×2.0") + assert body["group"] == ["BOX암거", "2.0 × 2.0", "본체"] and body["label"] == "m" + assert body["combos"] == ["system||SS000101", "system||SS000102"] + assert _cell(out, 300, "pipe:box_culvert|wing|inlet") == 1 # 옛 「있음」 = 날개벽 + assert _cell(out, 300, "pipe:box_culvert|wing|outlet") is None # 유출은 흙막이 + wing = _col(out, "pipe:box_culvert|wing|inlet") + assert wing["group"] == ["BOX암거", "날개벽", "유입"] and wing["label"] == "개소" + # 흙막이 빈 높이 = 박스 높이 · 공용 흙막이 열 + assert _cell(out, 300, "pipe:revet|돌쌓기(메)|2.0") == 10 + # 사용자 입력 규격 · 본체 길이 없음 = 빈칸 + 알림 + assert _col(out, "pipe:box|body|2.5×1.5")["group"][1] == "2.5×1.5" + assert _cell(out, 340, "pipe:box|body|2.5×1.5") is None + assert _cell(out, 340, "pipe:box_culvert|wing|outlet") == 1 + assert _cell(out, 340, "pipe:box_culvert|wing|inlet") is None + assert any("BOX 본체 길이 없음 1곳" in n for n in out["notes"]) + + +def test_세월교_월류폭_개소_날개벽_흙막이_미입력(out): + width = _col(out, "pipe:ford|1000|2|width") + assert width["group"] == ["세월교", "Φ1000×2련", "월류 폭"] and width["label"] == "m" + assert width["combos"] == ["system||SS000103"] # 정의 그룹 밖 잇기 = 저장된 잇기에서 + assert _cell(out, 400, "pipe:ford|1000|2|width") == 12 + assert _cell(out, 400, "pipe:ford|1000|2|count") == 1 + assert _col(out, "pipe:ford|1000|2|count")["label"] == "개소" + assert _cell(out, 400, "pipe:ford_bridge|wing|inlet") == 1 + assert _col(out, "pipe:ford_bridge|wing|inlet")["combos"] == ["system||SS000104"] + assert _cell(out, 400, "pipe:ford_bridge|wing|outlet") is None + soil = _col(out, "pipe:revet|돌쌓기(찰)|-") + assert soil["group"] == ["흙막이", "돌 쌓기(찰)", "미입력"] + assert _cell(out, 400, "pipe:revet|돌쌓기(찰)|-") == 7 + + +def test_물넘이_H별_월류폭(out): + pave = _col(out, "pipe:pave|1.0") + assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m" + assert _cell(out, 500, "pipe:pave|1.0") == 8 + + +def test_개거_노출형_맹암거_개소(out): + assert _cell(out, 600, "pipe:open_ditch") == 1 + assert _cell(out, 600, "pipe:cross_drain_exposed") == 1 + assert _cell(out, 700, "pipe:cross_underdrain") == 1 + assert _col(out, "pipe:open_ditch")["group"] == ["개거(겉도랑)"] + assert _col(out, "pipe:cross_underdrain")["combos"] == ["system||SS000105"] + + +def test_차례_계_종류(out): + ids = [c["id"] for c in out["columns"]] + order = [ + "pipe:revet|돌쌓기(찰)|-", + "pipe:revet|돌쌓기(메)|2.0", + "pipe:len|800", + "pipe:box|body|2.0×2.0", + "pipe:box|body|2.5×1.5", + "pipe:box_culvert|wing|inlet", + "pipe:ford|1000|2|width", + "pipe:ford|1000|2|count", + "pipe:ford_bridge|wing|inlet", + "pipe:pave|1.0", + "pipe:cross_drain_exposed", + "pipe:open_ditch", + "pipe:cross_underdrain", + ] + assert [ids.index(i) for i in order] == sorted(ids.index(i) for i in order) + assert out["total"]["pipe:box_culvert|wing|outlet"] == 1 + assert set(out["types"]) == { + "pipe", + "box_culvert", + "ford_bridge", + "ford_pavement", + "cross_drain_exposed", + "open_ditch", + "cross_underdrain", + } + + +def _width(*texts: str) -> int: + source = (ROOT / "B08_Quantity/B08_Quantity_Summary_Data.ts").read_text(encoding="utf-8") + source = re.sub(r"^import[^;]*;\n", "", source, flags=re.MULTILINE) + script = f"{source}\nconsole.log(widthOf(...{json.dumps(texts, ensure_ascii=False)}));" + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return int(result.stdout) + + +def test_열_폭_맨_아래_단_글만_44_100(): + assert _width("m") == 44 # 최소 + assert _width("관매설") == 52 # 한글 12 × 3 + 16 + assert _width("H=2.0") == 51 # 7 × 5 + 16 + assert _width("m", "1,234.56") == 72 # 칸 글이 길면 칸 글 + assert _width("흙막이 돌쌓기(메) 아주 긴 조합 이름") == 100 # 최대 · 머리는 줄바꿈 + + +def test_화면_조합_열_빼기_폭_머리_줄바꿈(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui + assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui + assert "widthOf(stationHead, ...labels)" in ui + css = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.css").read_text(encoding="utf-8") + assert '[data-b08-summary="table"] thead th' in css and "white-space: normal" in css + + +def test_패치가_박스_길이를_받는다(): + assert CrossSectionPatch(chainage_m=1, box_length_m=9.6).box_length_m == 9.6 + with pytest.raises(ValueError): + CrossSectionPatch(chainage_m=1, box_length_m=-1) + + +@needs_node +def test_서버가_박스_본체_길이를_주인_측점에만(): + from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE + from common_util.common_util_node_bundle import run_bundle_json + + data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) + sections = data["cross_sections"] + box_at = next(s for s in sections if s.get("box")) + neighbour = next(s for s in sections if s["chainage_m"] == 340) + neighbour["box"] = copy.deepcopy(box_at["box"]) + output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + rows = {round(r["chainage_m"], 3): r for r in output["areas"]} + assert rows[352.14]["box_length_m"] > 0 + assert "box_length_m" not in rows[340] diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index be9f0c99a..280be9535 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -220,7 +220,8 @@ def test_화면이_서버_칸을_그림(): base = Path(__file__).resolve().parents[2] ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui - assert '(point.facility ?? "pipe") !== "pipe"' in ui # 배관 조합 열 대신 서버 열 + # 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌) + assert '!served.has(point.facility ?? "pipe")' in ui assert "combosAt(things, station)" in ui # 모달은 배관 조합까지 그대로 locale = (base / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") assert 'B08_Quantity_Summary_Total: ["계", "Total"]' in locale