From 9a253ece97c8a7fc4bf8f5b07314cac6912b84d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:43:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EC=97=B0=EC=95=94=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EC=95=94=20=3D=20=EC=95=94=20=C3=97=20=EB=B9=84?= =?UTF-8?q?=EC=9C=A8(=EA=B8=B0=EB=B3=B8=2090=20:=2010)=20=C2=B7=20?= =?UTF-8?q?=ED=86=A0=EA=B3=B5=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=84=A4=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=ED=86=A0=EC=A0=81=ED=91=9C=20=EC=A0=88?= =?UTF-8?q?=ED=86=A0=20=EC=97=B0=EC=95=94=20=C2=B7=20=EA=B2=BD=EC=95=94=20?= =?UTF-8?q?=EB=91=90=20=EC=97=B4=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98(122-3)?= 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_01BVPp2o6Mh2FZJr72yW3ofZ --- .../B08_Quantity_Engine_Earthwork_Summary.py | 51 ++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 23 ++- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 24 ++- .../B08_Quantity_UI_Earthwork_Sheets.ts | 2 +- resources/tester/test_122_b08_options.py | 164 ++++++++++++++++ .../tester/test_122_b08_options_mirror.py | 177 ++++++++++++++++++ .../tester/test_69_b08_earthwork_tabs.py | 10 +- .../tester/test_rock_mixed_conversion.py | 2 +- 8 files changed, 434 insertions(+), 19 deletions(-) create mode 100644 resources/tester/test_122_b08_options.py create mode 100644 resources/tester/test_122_b08_options_mirror.py diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py index 2acad151a..080ea669c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -11,8 +11,11 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토 장비 띠(`blocks[].bands[]`)와 떨어진 구간 운반(`transfers[]`)의 `equipment` 로 가름 (`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). -암 갈래 — 리핑암 = 「연암」 줄 · 발파암 = 「경암」 줄. 풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 -값이 없어 비움(None). +암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122). +풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None). + +짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · +`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). """ from __future__ import annotations @@ -20,8 +23,16 @@ from __future__ import annotations from decimal import ROUND_HALF_UP, Decimal from typing import Any +from common_util.common_util_project_settings import SOFT_ROCK_PCT_DEFAULT + #: 운반 시트 열 — 장비 키 HAUL_EQUIPMENT = ("dump_truck", "dozer", "free_haul") +#: 암 칸 → 연암 칸 · 경암 칸(토적표 · 합계) · 칸 꼬리 셋 +ROCK_SPLIT = ( + ("cut_rock", "cut_soft_rock", "cut_hard_rock"), + ("ditch_rock", "ditch_soft_rock", "ditch_hard_rock"), +) +ROCK_SUFFIXES = ("area_m2", "volume_m3", "adjusted_m3") def excel_round(value: float, digits: int = 0) -> float: @@ -30,6 +41,24 @@ def excel_round(value: float, digits: int = 0) -> float: return float(Decimal(str(value)).quantize(step, rounding=ROUND_HALF_UP)) +def rock_split(value: float, soft_pct: float) -> tuple[float, float]: + """암 → (연암, 경암) — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암.""" + soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0 + return soft, value - soft + + +def split_rock_columns(record: dict[str, Any], soft_pct: float) -> dict[str, Any]: + """줄 · 합계의 암 칸마다 연암 · 경암 칸을 더함(숫자인 칸만).""" + for base, soft_key, hard_key in ROCK_SPLIT: + for suffix in ROCK_SUFFIXES: + value = record.get(f"{base}_{suffix}") + if isinstance(value, (int, float)) and not isinstance(value, bool): + soft, hard = rock_split(float(value), soft_pct) + record[f"{soft_key}_{suffix}"] = soft + record[f"{hard_key}_{suffix}"] = hard + return record + + def haul_lists(haul_plan: dict[str, Any] | None) -> dict[str, dict[str, Any]]: """장비별 `{rows: [{q, l, ql}], q, ql, average_m}` — 시트 한 줄 = 띠 · 운반 하나.""" items: dict[str, list[dict[str, float]]] = {key: [] for key in HAUL_EQUIPMENT} @@ -58,9 +87,15 @@ def _row(group: str, kind: str, spec: str, unit: str, value: float | None, note: return {"group": group, "kind": kind, "spec": spec, "unit": unit, "value": value, "note": note} -def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: - """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석).""" +def summary_rows( + totals: dict[str, float], + haul: dict[str, dict[str, Any]], + soft_pct: float = SOFT_ROCK_PCT_DEFAULT, +) -> list[dict[str, Any]]: + """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔.""" t = totals + cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct) + ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct) r0 = excel_round free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] diverted = t["diverted_m3"] @@ -69,14 +104,14 @@ def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> l return [ _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(t["cut_ripping_rock_volume_m3"])), + _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(cut_soft)), _row("흙깍기", "보통암", "굴삭기 + 브레카", m3, None), - _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(t["cut_blasting_rock_volume_m3"])), + _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(cut_hard)), _row("측구 터파기", "토사", "기계", m3, r0(t["ditch_soil_volume_m3"])), # 토적표!K137 _row("측구 터파기", "풍화암", "굴삭기 + 브레카", m3, None), - _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(t["ditch_ripping_rock_volume_m3"])), + _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(ditch_soft)), _row("측구 터파기", "보통암", "굴삭기 + 브레카", m3, None), - _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(t["ditch_blasting_rock_volume_m3"])), + _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(ditch_hard)), _row("보정량계", "", "", m3, r0(t["adjusted_total_m3"])), # 토적표!P137 _row("성토", "", "", m3, r0(t["fill_volume_m3"])), # 토적표!R137 _row("유용토 소계", "", "", m3, diverted + free_q), # G18+G19 diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index a7abaa3e1..9d249fe22 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -7,6 +7,7 @@ 단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) · 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`. +암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표. """ from __future__ import annotations @@ -24,9 +25,17 @@ from B06_Section.B06_Section_Repository import ( get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table -from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import haul_lists, summary_rows +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + haul_lists, + split_rock_columns, + summary_rows, +) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root -from common_util.common_util_project_settings import mixed_conversion_factors, quantity_settings +from common_util.common_util_project_settings import ( + mixed_conversion_factors, + quantity_settings, + soft_rock_pct, +) from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B08 Earthwork"]) @@ -44,14 +53,18 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]: route_id = int(context["route_id"]) rows = await get_cross_section_designs(connection, route_id) section = await get_longitudinal_section(connection, project_id, route_id) - factors = await asyncio.to_thread(lambda: mixed_conversion_factors(quantity_settings(root))) + settings = await asyncio.to_thread(quantity_settings, root) + soft = soft_rock_pct(settings) stations = [StationArea.from_design(row["chainage_m"], row["design"]) for row in rows] - table = build_table(stations, factors) + table = build_table(stations, mixed_conversion_factors(settings)) + for record in [*table["rows"], table["totals"]]: + split_rock_columns(record, soft) mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} haul = haul_lists(mass_haul.get("haul_plan")) return { **table, - "summary": summary_rows(table["totals"], haul), + "summary": summary_rows(table["totals"], haul, soft), + "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, "haul": haul, "haul_saved": bool(mass_haul.get("haul_plan")), "route_id": route_id, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 9952747d0..53049fa37 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -7,6 +7,7 @@ * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—). * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. + * 절토 연암 · 경암 = 암 × 좌측 패널 비율 — 조작 중 같은 식으로 바로 다시 셈(`…_Options_Calc` · PLAN 122). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -19,6 +20,8 @@ import { renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; +import { splitRockColumns, splitRockSummary } from "./B08_Quantity_Options_Calc"; +import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options"; type Face = { length_m: number | null; area_m2: number | null }; @@ -80,12 +83,15 @@ export interface EarthworkTable { summary: SummaryRow[]; haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; haul_saved: boolean; + /** 저장된 연암 · 경암 %(암 전체 기준) */ + rock_ratios_pct: { 연암: number; 경암: number }; } /** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ const TRIPLES: Array<[string[], string, string?]> = [ [["절토", "토사"], "cut_soil"], - [["절토", "연암"], "cut_rock"], + [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], + [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], [["측구파기", "토사"], "ditch_soil"], [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], ]; @@ -254,11 +260,23 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro return; } + // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율(캐시 → 저장본) + const summary = table.summary; + const applyRock = (): void => { + const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); + for (const record of [...table.rows, table.totals]) + splitRockColumns(record as unknown as Record, soft); + table.summary = splitRockSummary(summary, table.totals, soft); + }; + applyRock(); + const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; tabs.setAttribute("role", "tablist"); const view = document.createElement("div"); + let current: SheetId = "summary"; const select = (picked: SheetId): void => { + current = picked; tabs.replaceChildren( ...SHEETS.map(({ id, label }) => { const button = createButton({ @@ -280,4 +298,8 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro }; host.replaceChildren(tabs, view); select("summary"); + onOptionsChange(host, () => { + applyRock(); + select(current); + }); } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index bb9e1bc02..5f09a2827 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", + "연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py new file mode 100644 index 000000000..8e07ad11c --- /dev/null +++ b/resources/tester/test_122_b08_options.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""122 B08 좌측 패널 옵션 — 배관 1본 길이 · 연암 / 경암 비율 · 저장 길 · 화면 배선. + +1본 길이 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` · 비율 정본 = +`project_settings.json › quantity › rock_ratios_pct`(기본 90 : 10 은 파일에 안 씀). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Options as options +from B08_Quantity import B08_Quantity_Router_Summary as summary +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + excel_round, + haul_lists, + split_rock_columns, + summary_rows, +) +from common_util.common_util_project_settings import settings_path, soft_rock_pct +from M02_MasterTemplete import M02_Template_Layers as layers + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def root(tmp_path, monkeypatch) -> Path: + system = tmp_path / "master" + (system / "table").mkdir(parents=True) + (system / "table/구조물집계표.json").write_text( + json.dumps({"열": [], "변수": {"파형강관_1본_m": 8, "측점간격_m": 20}}), encoding="utf-8" + ) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + project = tmp_path / "project" + project.mkdir() + return project + + +def test_기본값은_8_m_와_90_10_파일에_안_씀(root): + assert options.read_options(root) == { + "one_pipe_length_m": 8, + "rock_ratios_pct": {"연암": 90.0, "경암": 10.0}, + } + assert not settings_path(root).exists() + assert not layers.template_path(layers.project_dir(root), "table", "구조물집계표").exists() + + +def test_1본_6_m_저장_뒤_커플링을_다시_셈(root): + assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1 + out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6)) + assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root) + assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2 + # 프로젝트 층 사본 = 시스템 문서 그대로 + 변수 한 칸 · 시스템 층은 안 바뀜 + mine = layers.read_template(layers.project_dir(root), "table", "구조물집계표")["문서"] + assert mine["변수"] == {"파형강관_1본_m": 6, "측점간격_m": 20} and mine["열"] == [] + system = layers.read_template(layers.system_dir(), "table", "구조물집계표")["문서"] + assert system["변수"]["파형강관_1본_m"] == 8 + # 다시 저장 = 같은 사본 판 위에 씀 + options.save_options(root, options.OptionsSave(one_pipe_length_m=8)) + assert summary.one_pipe_length(root) == 8 + + +def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root): + settings_path(root).write_text( + json.dumps( + {"quantity": {"rock_ratios_pct": {"풍화암": 5}, "rock_methods": {"연암": "ripping"}}} + ), + encoding="utf-8", + ) + out = options.save_options(root, options.OptionsSave(soft_rock_pct=70)) + assert out["rock_ratios_pct"] == {"연암": 70.0, "경암": 30.0} + stored = json.loads(settings_path(root).read_text(encoding="utf-8"))["quantity"] + assert stored["rock_ratios_pct"] == {"풍화암": 5, "연암": 70.0, "경암": 30.0} + assert stored["rock_methods"] == {"연암": "ripping"} + assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0 + # 1본 길이는 안 보냈으니 그대로 + assert options.read_options(root)["one_pipe_length_m"] == 8 + + +def test_비율_읽기_자름_경암만_있을_때(): + assert soft_rock_pct({}) == 90 + assert soft_rock_pct({"rock_ratios_pct": {"연암": 130}}) == 100 + assert soft_rock_pct({"rock_ratios_pct": {"경암": 25}}) == 75 + assert soft_rock_pct({"rock_ratios_pct": {"연암": True}}) == 90 + + +def test_범위_밖_값은_거절(): + with pytest.raises(ValueError): + options.OptionsSave(one_pipe_length_m=0) + with pytest.raises(ValueError): + options.OptionsSave(soft_rock_pct=101) + + +def test_토공집계표_연암_경암은_암_곱하기_비율_합_100(): + totals = { + "cut_soil_volume_m3": 10.0, + "cut_rock_volume_m3": 47.6, + "ditch_soil_volume_m3": 1.0, + "ditch_rock_volume_m3": 2.3, + "adjusted_total_m3": 60.0, + "fill_volume_m3": 5.0, + "diverted_m3": 5.0, + "balance_m3": 50.0, + "benching_area_m2": 0.0, + "grading_fill_area_m2": 0.0, + "grading_cut_area_m2": 0.0, + "tree_removal_fill_area_m2": 0.0, + "tree_removal_cut_area_m2": 0.0, + } + rows = summary_rows(totals, haul_lists(None), 70) + value = {(r["group"], r["kind"]): r["value"] for r in rows} + assert value[("흙깍기", "연암")] == excel_round(47.6 * 0.7) == 33 + assert value[("흙깍기", "경암")] == excel_round(47.6 - 47.6 * 0.7) == 14 + assert value[("측구 터파기", "연암")] == 2 and value[("측구 터파기", "경암")] == 1 + # 토적표 칸 · 합계 — 연암 + 경암 = 암 + record = split_rock_columns({"cut_rock_area_m2": 1.2, "cut_rock_volume_m3": 47.6}, 70) + assert record["cut_soft_rock_area_m2"] == pytest.approx(0.84) + assert record["cut_soft_rock_volume_m3"] + record["cut_hard_rock_volume_m3"] == pytest.approx( + 47.6 + ) + + +def test_라우터_등록_응답_칸(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b08_options_router, dependencies=protected_with_company)" in main + earthwork = (ROOT / "B08_Quantity/B08_Quantity_Router_Earthwork.py").read_text(encoding="utf-8") + assert "split_rock_columns(record, soft)" in earthwork and '"rock_ratios_pct"' in earthwork + pipe = (ROOT / "B08_Quantity/B08_Quantity_Router_Summary.py").read_text(encoding="utf-8") + assert '"pipes": pipes' in pipe + + +def test_좌측_패널_컨테이너_셋과_저장_줄(): + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") + panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") + assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page + for tab in ("structure", "earthwork", "hydrology"): + assert f'card("{tab}", "B08_Quantity_Tab_' in panel, tab + assert "ui-collapsible ui-sidebar-section ui-sidebar-card" in panel + assert ( + 'actions.className = "ui-sidebar-actions"' in panel and "attachCollapsible(panel)" in panel + ) + assert len(re.findall(r'type: "number",\s+compact: true', panel)) == 3 + # 조작 = 캐시 · [저장] = PUT 하나 · 자동 저장 없음 + assert 'writeState("b08-options"' in panel and 'clearState("b08-options"' in panel + assert panel.count('method: "PUT"') == 1 + registry = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8") + assert '"b08-options": { bucket: "draft", scope: "project" }' in registry + + +def test_표가_옵션_바뀜에_바로_다시_셈(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui + assert "onOptionsChange(host," in ui + earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + assert "splitRockSummary(summary, table.totals, soft)" in earth + assert "onOptionsChange(host," in earth + assert ( + '[["절토", "연암"], "cut_soft_rock"' in earth + and '[["절토", "경암"], "cut_hard_rock"' in earth + ) diff --git a/resources/tester/test_122_b08_options_mirror.py b/resources/tester/test_122_b08_options_mirror.py new file mode 100644 index 000000000..a24139945 --- /dev/null +++ b/resources/tester/test_122_b08_options_mirror.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +"""122 거울 — 좌측 패널 옵션 식 · 파이썬 ↔ TS(`B08_Quantity_Options_Calc.ts`). + +조작 중 브라우저가 세는 값 = [저장] 뒤 서버가 세는 값이어야 함: + 커플링(관마다 올림(L ÷ 1본) − 1 · 같은 측점 더함) · 암 → 연암 · 경암 칸 · 토공집계표 네 줄. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + haul_lists, + rock_split, + split_rock_columns, + summary_rows, +) +from B08_Quantity.B08_Quantity_Router_Summary import couplings + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +LENGTHS = [None, 0, 5, 8, 8.0000000001, 16, 17, 22, 23.5, 48] +ONES = [None, 6, 8, 2.5] +PCTS = [0, 10, 33.3, 70, 90, 100, 130, -5] +ROCKS = [0.0, 1.0, 2.3, 47.6, 123.456] +PIPES = [ + {"chainage_m": 1433.0, "column": "pipe:cp|1200", "length_m": 22}, + {"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 18}, + {"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 9}, # 같은 측점 둘째 관 + {"chainage_m": 1680.0, "column": "pipe:cp|800", "length_m": 16}, + {"chainage_m": 1790.0, "column": "pipe:cp|800", "length_m": None}, +] +SUMMARY = { + "rows": [ + {"chainage_m": 1433.0, "values": {"pipe:len|1200": 22, "pipe:cp|1200": 99}}, + {"chainage_m": 1567.0, "values": {"pipe:len|800": 27, "pipe:cp|800": 99}}, + {"chainage_m": 1680.0, "values": {"pipe:len|800": 16, "pipe:cp|800": 99}}, + {"chainage_m": 1790.0, "values": {}}, + ], + "total": {"pipe:len|1200": 22, "pipe:len|800": 43, "pipe:cp|1200": 99, "pipe:cp|800": 99}, + "pipes": PIPES, +} +TOTALS = { + "cut_soil_volume_m3": 10.0, + "cut_rock_volume_m3": 47.6, + "ditch_soil_volume_m3": 1.0, + "ditch_rock_volume_m3": 2.3, + "adjusted_total_m3": 60.0, + "fill_volume_m3": 5.0, + "diverted_m3": 5.0, + "balance_m3": 50.0, + "benching_area_m2": 0.0, + "grading_fill_area_m2": 0.0, + "grading_cut_area_m2": 0.0, + "tree_removal_fill_area_m2": 0.0, + "tree_removal_cut_area_m2": 0.0, +} +RECORD = { + "cut_rock_area_m2": 1.2, + "cut_rock_volume_m3": 47.6, + "cut_rock_adjusted_m3": 54.74, + "ditch_rock_area_m2": 0.18, + "ditch_rock_volume_m3": 2.3, + "ditch_rock_adjusted_m3": None, +} + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as calc from "./B08_Quantity_Options_Calc.js"; +const input = JSON.parse(process.argv[3]); +const out = { + couplings: input.lengths.map((l) => input.ones.map((o) => calc.couplings(l, o))), + splits: input.rocks.map((r) => input.pcts.map((p) => calc.rockSplit(r, p))), + records: input.pcts.map((p) => { const r = { ...input.record }; calc.splitRockColumns(r, p); return r; }), + summary: input.pcts.map((p) => calc.splitRockSummary(input.rows, input.totals, p)), + recount: input.ones.map((o) => calc.recountCouplings(input.summary, o)), +}; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +def _ts(tmp_path: Path, payload: dict) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result), json.dumps(payload)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def _py_recount(one: float | None) -> dict: + """서버 `_Sheet` 와 같은 모음 — 관마다 couplings · 같은 측점(1 cm) 더함 · 계 = 줄 합.""" + rows = [] + for row in SUMMARY["rows"]: + values = {k: v for k, v in row["values"].items() if not k.startswith("pipe:cp|")} + for pipe in PIPES: + count = couplings(pipe["length_m"], one) + if count is None or round(pipe["chainage_m"] * 100) != round(row["chainage_m"] * 100): + continue + values[pipe["column"]] = values.get(pipe["column"], 0) + count + rows.append({"chainage_m": row["chainage_m"], "values": values}) + total = {k: v for k, v in SUMMARY["total"].items() if not k.startswith("pipe:cp|")} + for row in rows: + for key, value in row["values"].items(): + if key.startswith("pipe:cp|"): + total[key] = total.get(key, 0) + value + return {**SUMMARY, "rows": rows, "total": total} + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None: + rows = summary_rows(TOTALS, haul_lists(None), 90) + ts = _ts( + tmp_path, + { + "lengths": LENGTHS, + "ones": ONES, + "rocks": ROCKS, + "pcts": PCTS, + "record": RECORD, + "rows": rows, + "totals": TOTALS, + "summary": SUMMARY, + }, + ) + assert ts["couplings"] == [[couplings(l, o) for o in ONES] for l in LENGTHS] + assert ts["splits"] == [[list(rock_split(r, p)) for p in PCTS] for r in ROCKS] + for pct, got in zip(PCTS, ts["records"]): + assert got == split_rock_columns(dict(RECORD), pct), pct + for pct, got in zip(PCTS, ts["summary"]): + assert got == summary_rows(TOTALS, haul_lists(None), pct), pct + for one, got in zip(ONES, ts["recount"]): + assert got == _py_recount(one), one + + +def test_1본_6_m_면_커플링이_바뀜(tmp_path: Path) -> None: + six, eight = _py_recount(6), _py_recount(8) + assert six["total"]["pipe:cp|800"] == 2 + 1 + 2 and eight["total"]["pipe:cp|800"] == 2 + 1 + 1 + assert six["total"]["pipe:cp|1200"] == 3 and eight["total"]["pipe:cp|1200"] == 2 + + +def test_식_모양이_두_쪽_같다() -> None: + ts = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8") + py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_Earthwork_Summary.py").read_text("utf-8") + assert "Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0)" in ts + assert "const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;" in ts + assert "soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0" in py + assert not re.search(r"^import ", ts, re.M) # 시험이 이 파일만 옮겨 돌림 diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 9f7feb2d5..f29e0890a 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -182,9 +182,12 @@ def test_토공집계표_식() -> None: rows = summary_rows(t, haul_lists(HAUL_PLAN)) value = {(r["group"], r["kind"], r["spec"]): r["value"] for r in rows} assert value[("흙깍기", "토사", "기계(굴삭기)")] == excel_round(t["cut_soil_volume_m3"]) - assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == 48 - assert value[("흙깍기", "경암", "발파 + 굴삭기")] == 0 - assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == 2 + # 연암 · 경암 = 암 × 기본 90 : 10(122) + rock, ditch = t["cut_rock_volume_m3"], t["ditch_rock_volume_m3"] + assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == excel_round(rock * 0.9) == 43 + assert value[("흙깍기", "경암", "발파 + 굴삭기")] == excel_round(rock - rock * 0.9) == 5 + assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == excel_round(ditch * 0.9) + assert value[("측구 터파기", "경암", "발파 + 굴삭기")] == excel_round(ditch - ditch * 0.9) assert value[("보정량계", "", "")] == 91 assert value[("성토", "", "")] == 189 assert value[("종방향유용토", "", "백호우")] == 82 @@ -205,6 +208,7 @@ def test_토적표_머리_글자는_사용자_견본대로(): ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") for head in ( '["절토", "연암"]', + '["절토", "경암"]', '["측구파기", "토사"]', '["측구파기", "암"]', "리핑암 · 발파암 합", diff --git a/resources/tester/test_rock_mixed_conversion.py b/resources/tester/test_rock_mixed_conversion.py index 639823a94..ca72dbbf4 100644 --- a/resources/tester/test_rock_mixed_conversion.py +++ b/resources/tester/test_rock_mixed_conversion.py @@ -63,4 +63,4 @@ def test_토적표_유토곡선_운반표가_같은_함수를_씀() -> None: router = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") assert prebuild.count("mixed_conversion_factors(") == 2 assert "earthwork_conversion_factors(" not in prebuild - assert router.count("mixed_conversion_factors(settings)") == 2 + assert router.count("mixed_conversion_factors(settings)") == 1