Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -96,7 +96,8 @@ def test_참조_줄_재료_연료_뺌_고른_품목(tmp_path, monkeypatch):
|
||||
price = {"묶음": {"줄": [{"키": "MT9", "이름": "레디믹스트 레미콘", "규격": "25-18-80"}]}}
|
||||
frozen = {
|
||||
ref_id(SC): {
|
||||
"담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP}],
|
||||
# 굳힌 옛 사본 = 안 줄 손 다 있음(723-1 앞 · 빈 수량 = 이은 칸 값 · 없으면 안 셈)
|
||||
"담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP, "수량": {"손": 1}}],
|
||||
"알약": [{"로직": ["L1"], "배": 10}],
|
||||
},
|
||||
ref_id(SP): {"담은로직": [{"로직": "L2"}], "배": 4},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가
|
||||
고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400).
|
||||
175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄
|
||||
175-2 셈 — 담은 자재(수량 = 이은 알약 값 · 723-1 · 손은 저장 안 함)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄
|
||||
재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감.
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
|
||||
from resources.tester.test_m02_labor_ref import _area, world # noqa: F401 — 임시 층 fixture
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
|
||||
@@ -35,11 +35,17 @@ def _save(folder, doc):
|
||||
|
||||
|
||||
def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811
|
||||
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
|
||||
assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail
|
||||
assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail
|
||||
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail
|
||||
assert "delete out.자재" not in detail and "delete out.열" in detail
|
||||
"""723-2 — 서고 = 일위대가표만(`Labor_Table`) · [자재 추가] 머리 · 세 칸 = 줄 모달(`Labor_RowModal`)."""
|
||||
read = lambda name: open(os.path.join(SRC, name), encoding="utf-8").read() # noqa: E731
|
||||
detail, lib = (
|
||||
read("M02_MasterTemplete_Labor_Detail.ts"),
|
||||
read("M02_MasterTemplete_Labor_Table.ts"),
|
||||
)
|
||||
modal = read("M02_MasterTemplete_Labor_RowModal.ts")
|
||||
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
|
||||
assert 'className: "ui-btn-group", children: [addLogic, addMat]' in lib
|
||||
assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal
|
||||
assert "delete out.자재" not in lib and "delete out.열" in lib
|
||||
|
||||
|
||||
def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811
|
||||
@@ -51,7 +57,7 @@ def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811
|
||||
_, got = _save(world["system"], {"담은로직": [line], "자재": mats})
|
||||
one = got["담은로직"][0]
|
||||
assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}}
|
||||
assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)]
|
||||
assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, None), (WIRE, None)] # 723-1
|
||||
assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름"
|
||||
# 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘)
|
||||
_, empty = _save(world["system"], {"담은로직": [{"로직": NET}]})
|
||||
@@ -73,8 +79,23 @@ def _price(code):
|
||||
return Decimal(str(labor.material_price(whole, code, None)["기준단가"]))
|
||||
|
||||
|
||||
def _given(key):
|
||||
"""안 줄 알약 값(723-1) — STONE 1 · NET 0 · MAT 2 · WIRE 0."""
|
||||
rid = refs.ref_id({**SYS, "키": key})
|
||||
return {f"{rid}#0": 1, f"{rid}#1": 0, f"{rid}#자재|{MAT}": 2, f"{rid}#자재|{WIRE}": 0}
|
||||
|
||||
|
||||
def _legacy(world, key): # noqa: F811
|
||||
"""옛 손 수량 문서(723-1 앞 · 굳힌 사본 꼴) — B08 · B09 셈 길 시험용 · 파일은 안 바꿈."""
|
||||
doc = json.loads((world["system"] / "precombo" / f"{key}.json").read_bytes())
|
||||
hands = ({"손": 1}, {"손": 0})
|
||||
lines = [{**one, "수량": q} for one, q in zip(doc["담은로직"], hands)]
|
||||
mats = [{**m, "수량": {"손": 2} if m["코드"] == MAT else None} for m in doc["자재"]]
|
||||
return {**doc, "담은로직": lines, "자재": mats}
|
||||
|
||||
|
||||
def _world_combo(world): # noqa: F811
|
||||
"""STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값)."""
|
||||
"""STONE 1 + MAT 2 + WIRE 0 · NET 고름 4 = NET_ITEM — (키, 1 단위 값) · 수량은 `_given`."""
|
||||
lines = [
|
||||
{"로직": STONE, "수량": {"손": 1}},
|
||||
{"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}},
|
||||
@@ -95,34 +116,42 @@ def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(w
|
||||
doc = labor.clean(
|
||||
{"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}
|
||||
)
|
||||
one = labor.calc(doc, {})["줄"][0]
|
||||
bare = labor.calc(doc, {})["줄"][0]
|
||||
one = labor.calc(doc, _given(key))["줄"][0]
|
||||
assert bare["기준"] is None and "수량 없음" in bare["까닭"] # 723-1 — 안 이으면 값 없음
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 3))
|
||||
mats = [x for x in one["안"] if x.get("자재")]
|
||||
assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈
|
||||
assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE = 기본 별도계상(안 셈)
|
||||
assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT)))
|
||||
# 목록 기준금액(51-4)도 같은 펼침
|
||||
# 목록 기준금액(51-4) — 이은 값 없음 = 빈 값(723-1)
|
||||
rows = [{"종류": "precombo", "이름": key}]
|
||||
refs.summaries(world["system"], rows)
|
||||
assert rows[0]["기준금액"] == pytest.approx(float(want))
|
||||
assert rows[0]["기준금액"] is None
|
||||
|
||||
|
||||
def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811
|
||||
from B08_Quantity.B08_Quantity_Router_Material import _ref_mats
|
||||
|
||||
key, _ = _world_combo(world)
|
||||
with store.at(world["system"]):
|
||||
outer = refs.create(
|
||||
"precombo",
|
||||
{
|
||||
"이름": "겹",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
|
||||
outer = {
|
||||
"이름": "겹",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}],
|
||||
}
|
||||
docs = {"SC-겹": outer, key: _legacy(world, key)} # 옛 손 문서(723-1 앞 꼴 · B08 길만 잼)
|
||||
got = _ref_mats(m01.loaded()[1], lambda r: docs.get(r["키"]), {**SYS, "키": "SC-겹"})
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음
|
||||
# 723-1 — 수량 없는 새 문서 = 면적 알약 값(given)으로 · 안 이으면 안 셈
|
||||
live = {"SC-겹": outer, key: refs.read_ref({**SYS, "키": key})}
|
||||
bare = _ref_mats(m01.loaded()[1], lambda r: live.get(r["키"]), {**SYS, "키": "SC-겹"})
|
||||
assert [m for m, _ in bare if m.get("자재")] == []
|
||||
got = _ref_mats(
|
||||
m01.loaded()[1], lambda r: live.get(r["키"]), {**SYS, "키": "SC-겹"}, given=_given(key)
|
||||
)
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
assert mats == [(MAT, pytest.approx(6.0))]
|
||||
|
||||
|
||||
def test_B09_겹_대가에_자재_줄(world): # noqa: F811
|
||||
@@ -131,7 +160,7 @@ def test_B09_겹_대가에_자재_줄(world): # noqa: F811
|
||||
key, _ = _world_combo(world)
|
||||
ref = {**SYS, "키": key}
|
||||
build = Builder()
|
||||
bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {})
|
||||
bid = build.precombo(m01.loaded()[1], ref, _legacy(world, key), {}) # 옛 손 문서(723-1 앞)
|
||||
mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"]
|
||||
assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)]
|
||||
assert mats[0]["name"] and mats[0]["unit"]
|
||||
@@ -150,5 +179,5 @@ def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(wo
|
||||
assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read()
|
||||
bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats))
|
||||
(world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본
|
||||
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
|
||||
got = prices.calc(rows, bundle, {}, {}, _area(_given(key)))["줄"][0] # 사본 면적 칸 = 안 줄 값
|
||||
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
|
||||
|
||||
@@ -27,7 +27,7 @@ if str(ROOT) not in sys.path:
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import ASK, daega_inputs # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402
|
||||
from config import config_system # noqa: E402
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
|
||||
@@ -62,7 +62,9 @@ def world(tmp_path, monkeypatch):
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"]
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})[
|
||||
"이름"
|
||||
]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
@@ -84,7 +86,10 @@ def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사
|
||||
got, block = _block(root, folder)
|
||||
assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡")
|
||||
kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]]
|
||||
assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)]
|
||||
# 723-1 · 723-4 = M02 SC 안 줄 수량 저장 안 함 → 내역에서 바로 쓴 SC = 수량 없음(이 공사에서 입력)
|
||||
assert kinds == [("sangeun", None), ("sangeun", None), ("material", None)]
|
||||
assert all(ln["ask"] == ASK for ln in block["lines"])
|
||||
assert block["cover"] == f"C:{block['origin']['참조']}" # 공사 안 한 덮개 그대로
|
||||
assert got["owners"] == {} # 구조물 사본 없어도 호표
|
||||
# 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음)
|
||||
bundle = json.loads((copy / "단가.json").read_text("utf-8"))
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""640 라이다 기본 고름 · implicit 점 수 · 격자 칸 뽑기 · 지문 · 틈 닫기 (요구 558).
|
||||
|
||||
서버 함수만 — 작은 합성 점군 · 임시 폴더.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
classified_coverage,
|
||||
has_classified_coverage,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import implicit_point_count
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models
|
||||
from config import config_system as cfg
|
||||
|
||||
|
||||
def _plane(size_m: float = 60.0, spacing_m: float = 0.5) -> np.ndarray:
|
||||
axis = np.arange(0.0, size_m, spacing_m)
|
||||
x, y = np.meshgrid(axis, axis)
|
||||
z = 100.0 + 0.2 * x + 0.1 * np.sin(y / 5.0)
|
||||
return np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
||||
|
||||
|
||||
def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict:
|
||||
data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T}
|
||||
if classification is not None:
|
||||
data["classification"] = classification
|
||||
return data
|
||||
|
||||
|
||||
def test_확정_기본값은_implicit_평활_끔() -> None:
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_METHOD == "implicit"
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_SMOOTH is False
|
||||
assert cfg.SURFACE_AUTO_METHODS == ("dtm",) # D03 자체 라이다는 dtm 만 그대로
|
||||
|
||||
|
||||
def test_일부만_분류된_LAS_는_자동_기본에서_csf() -> None:
|
||||
points = _plane(100.0)
|
||||
partial = np.ones(len(points), dtype=np.uint8)
|
||||
partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 %
|
||||
assert classified_coverage(_structured(points, partial)) < 0.5
|
||||
assert resolve_auto_source_filters(_structured(points, partial)) == ["csf"]
|
||||
|
||||
spread = np.ones(len(points), dtype=np.uint8)
|
||||
spread[::37] = 2 # 비율 2.7 % · 덮임 100 %
|
||||
assert classified_coverage(_structured(points, spread)) == 1.0
|
||||
assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "csf"]
|
||||
|
||||
|
||||
def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None:
|
||||
points = _plane(40.0)
|
||||
classes = np.full(len(points), 2, dtype=np.uint8)
|
||||
(tmp_path / "structured.npz").write_bytes(b"x")
|
||||
assert has_classified_coverage(_structured(points, classes), tmp_path)
|
||||
assert (tmp_path / "classification_coverage.json").is_file()
|
||||
|
||||
class _NoXyz(dict):
|
||||
def __getitem__(self, key): # type: ignore[no-untyped-def]
|
||||
assert key != "xyz", "저장본이 맞으면 점군을 풀지 않아야 함"
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
assert has_classified_coverage(_NoXyz(_structured(points, classes)), tmp_path)
|
||||
|
||||
|
||||
def _config(**override: object) -> dict:
|
||||
config = cfg.build_surface_model_config()
|
||||
config.update({"source_filters": ["a", "b"], "precompute": ["dtm", "implicit"]})
|
||||
config.update(override)
|
||||
return config
|
||||
|
||||
|
||||
def test_implicit_은_기본_필터에만(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool), "b": np.arange(len(points)) % 2 == 0}
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, _config())
|
||||
assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"}
|
||||
assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"}
|
||||
assert not (tmp_path / "implicit_b.npz").exists()
|
||||
|
||||
|
||||
def test_implicit_점_수_비율_하한_상한() -> None:
|
||||
config = cfg.build_surface_model_config()
|
||||
assert (config["implicit_point_ratio"], config["implicit_min_points"]) == (0.175, 50_000)
|
||||
assert config["implicit_max_points"] == 2_000_000
|
||||
assert implicit_point_count(1_140_716, config) == 199_625 # 용화 분류
|
||||
assert implicit_point_count(768_273, config) == 134_448 # 용화 CSF
|
||||
assert implicit_point_count(100_000, config) == 50_000 # 하한
|
||||
assert implicit_point_count(30_000, config) == 30_000 # 하한보다 적으면 전부
|
||||
assert implicit_point_count(50_000_000, config) == 2_000_000 # 상한
|
||||
|
||||
|
||||
def test_격자_칸_뽑기는_고르고_씨앗_고정() -> None:
|
||||
rng = np.random.default_rng(1)
|
||||
dense = rng.uniform(0, 20, (200_000, 2)) # 왼쪽 아래 트인 곳 — 점 몰림
|
||||
sparse = rng.uniform(0, 100, (30_000, 2)) # 전체 성긴 숲 밑
|
||||
xy = np.vstack([dense, sparse])
|
||||
points = np.column_stack([xy, np.zeros(len(xy))])
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=cfg.build_surface_model_config(),
|
||||
)
|
||||
picked = context.grid_sample(5_000)
|
||||
assert len(picked) == 5_000 and len(np.unique(picked[:, :2], axis=0)) == 5_000
|
||||
assert np.array_equal(picked, context.grid_sample(5_000))
|
||||
in_dense = np.count_nonzero((picked[:, 0] < 20) & (picked[:, 1] < 20)) / len(picked)
|
||||
file_order = context.sample(5_000)
|
||||
in_dense_file = np.count_nonzero((file_order[:, 0] < 20) & (file_order[:, 1] < 20))
|
||||
assert in_dense < 0.1 < 0.5 < in_dense_file / len(file_order) # 파일 차례는 몰림 · 격자는 고름
|
||||
assert len(context.grid_sample(300_000)) == len(points) # 모자라면 전부
|
||||
|
||||
|
||||
def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool)}
|
||||
config = _config(
|
||||
source_filters=["a"],
|
||||
precompute=["implicit"],
|
||||
implicit_min_points=500,
|
||||
implicit_max_points=1_000,
|
||||
)
|
||||
build_all_terrain_models(data, masks, tmp_path, config)
|
||||
with np.load(tmp_path / "implicit_a.npz") as old:
|
||||
assert len(old["center_z"]) == 1_000
|
||||
changed = dict(config, implicit_max_points=2_000)
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, changed)
|
||||
with np.load(tmp_path / "implicit_a.npz") as new:
|
||||
assert len(new["center_z"]) == 2_000
|
||||
assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000
|
||||
|
||||
|
||||
def test_외곽_틈_닫기_3m_는_성긴_띠를_살림() -> None:
|
||||
points = _plane(60.0)
|
||||
gap = (points[:, 0] > 28) & (points[:, 0] < 33) # 5 m 빈 띠 — 1 m 닫기면 두 덩이
|
||||
points = points[~gap]
|
||||
|
||||
def area(close_m: float) -> float:
|
||||
config = dict(cfg.build_surface_model_config(), footprint_gap_close_meters=close_m)
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=config,
|
||||
)
|
||||
return context.footprint_metadata()["footprint_area_m2"]
|
||||
|
||||
assert cfg.SURFACE_FOOTPRINT_GAP_CLOSE_M == 3.0
|
||||
assert area(3.0) > 1.8 * area(1.0) # 한 덩이(띠 포함) 대 큰 덩이 하나
|
||||
@@ -0,0 +1,177 @@
|
||||
"""PLAN 641 — implicit 굳힌 격자(`implicit_{필터}_grid.npz`) · 화면 일치 · B07 · D03 그대로.
|
||||
|
||||
641-1: 굳힌 격자 축 · 유효 = 같은 필터 dtm · 유효 칸 값 = RBF 값 · 밖 = dtm 값 · 옛것이면 다시 셈 ·
|
||||
서버 표본기 · Node 측점(`source`) · B06 재계산 · 브라우저 격자 통로가 같은 파일.
|
||||
641-2: implicit 확정이어도 B07 음영 · D03 지형은 평활 DTM · dtm 확정은 전과 같음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
|
||||
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild
|
||||
from common_util.common_util_surface_sampler import (
|
||||
DtmGridSampler,
|
||||
build_surface_sampler,
|
||||
dtm_grid_bytes,
|
||||
implicit_grid_npz,
|
||||
implicit_rbf,
|
||||
)
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_TerrainSource import road_dem
|
||||
|
||||
IMPLICIT = {"source_filter": "classification", "method": "implicit", "smooth": False}
|
||||
|
||||
|
||||
def _surface(x, y):
|
||||
return 100.0 + 0.2 * x + 0.1 * y + 2.0 * np.sin(x / 7.0) * np.cos(y / 5.0)
|
||||
|
||||
|
||||
def _project(tmp_path: Path) -> Path:
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
models.mkdir(parents=True)
|
||||
axis = np.arange(0.0, 40.0, dtype=np.float32)
|
||||
valid = np.zeros((40, 40), dtype=bool)
|
||||
valid[5:35, 3:30] = True
|
||||
for name, z in (("dtm_classification", 50.0), ("dtm_classification_smooth", 60.0)):
|
||||
np.savez(
|
||||
models / f"{name}.npz",
|
||||
x=axis,
|
||||
y=axis,
|
||||
z=np.full((40, 40), z, np.float32),
|
||||
valid_mask=valid,
|
||||
resolution=np.array([1.0], np.float32),
|
||||
)
|
||||
rng = np.random.default_rng(641)
|
||||
centers = rng.uniform(0.0, 39.0, (600, 2)).astype(np.float32)
|
||||
np.savez(
|
||||
models / "implicit_classification.npz",
|
||||
centers_xy=centers,
|
||||
center_z=_surface(centers[:, 0], centers[:, 1]).astype(np.float32),
|
||||
smoothing=np.array([0.1], np.float32),
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
def test_굳힌_격자_축_유효_dtm_같음_값_RBF(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
path = implicit_grid_npz(models, "classification")
|
||||
assert path.name == "implicit_classification_grid.npz"
|
||||
with np.load(path) as grid, np.load(models / "dtm_classification.npz") as dtm:
|
||||
assert np.array_equal(grid["x"], dtm["x"]) and np.array_equal(grid["y"], dtm["y"])
|
||||
assert np.array_equal(grid["valid_mask"], dtm["valid_mask"])
|
||||
z, valid = grid["z"], grid["valid_mask"]
|
||||
assert not np.isnan(z).any()
|
||||
assert np.array_equal(z[~valid], dtm["z"][~valid]) # 밖 = dtm 값
|
||||
rows, cols = np.nonzero(valid)
|
||||
query = np.column_stack([grid["x"][cols], grid["y"][rows]]).astype(np.float64)
|
||||
rbf = implicit_rbf(models / "implicit_classification.npz")
|
||||
assert np.abs(rbf(query) - z[rows, cols]).max() < 1e-4 # 유효 칸 = RBF(float32)
|
||||
|
||||
|
||||
def test_옛것이면_다시_셈_같으면_그대로(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
path = implicit_grid_npz(models, "classification")
|
||||
stamp = path.stat().st_mtime_ns
|
||||
assert implicit_grid_npz(models, "classification").stat().st_mtime_ns == stamp
|
||||
source = models / "implicit_classification.npz"
|
||||
os.utime(source, ns=(stamp + 10**9, stamp + 10**9))
|
||||
assert implicit_grid_npz(models, "classification").stat().st_mtime_ns > stamp
|
||||
|
||||
|
||||
def test_서버_Node_B06_브라우저_같은_파일(tmp_path, monkeypatch):
|
||||
models = _project(tmp_path)
|
||||
sampler = build_surface_sampler(models, "classification", "implicit", False)
|
||||
grid_npz = models / "implicit_classification_grid.npz"
|
||||
assert isinstance(sampler, DtmGridSampler)
|
||||
assert Path(sampler.source) == grid_npz.resolve() # Node 측점 = dtm_grid_file(source)
|
||||
|
||||
# 서버 표본 = 굳힌 격자 표본(같은 측점 같은 표고)
|
||||
points = np.array([[10.3, 12.7], [20.0, 20.0], [1.0, 1.0]])
|
||||
z, valid = sampler.sample_xy(points)
|
||||
z2, valid2 = DtmGridSampler.from_npz(grid_npz).sample_xy(points)
|
||||
assert np.array_equal(valid, valid2) and np.allclose(z[valid], z2[valid2], atol=0)
|
||||
assert valid.tolist() == [True, True, False]
|
||||
|
||||
# B06 서버 재계산 격자 = 같은 npz 의 `.bin`
|
||||
bin_path = prebuild.design_grid_file(tmp_path, IMPLICIT)
|
||||
assert bin_path is not None and bin_path.read_bytes() == dtm_grid_bytes(grid_npz)
|
||||
|
||||
# 브라우저 격자 통로 — implicit 모델 행 → 굳힌 격자
|
||||
row = ("implicit", "B04_PreProcess/models/implicit_classification.npz")
|
||||
|
||||
class _Cursor:
|
||||
async def execute(self, *_args):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return row
|
||||
|
||||
class _Connection:
|
||||
@asynccontextmanager
|
||||
async def cursor(self):
|
||||
yield _Cursor()
|
||||
|
||||
async def stored(_connection, _project_id):
|
||||
return "x"
|
||||
|
||||
monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored)
|
||||
monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: tmp_path)
|
||||
served = asyncio.run(terrain_router.dtm_grid_npz(_Connection(), "p", 9, False))
|
||||
assert served.resolve() == grid_npz.resolve()
|
||||
|
||||
info = terrain_router._design_grid(
|
||||
IMPLICIT, models, (9, "implicit", "B04_PreProcess/models/implicit_classification.npz")
|
||||
)
|
||||
assert info is not None and info["model_id"] == 9 and info["smooth"] is False
|
||||
assert (
|
||||
terrain_router._design_grid(IMPLICIT, models, (3, "dtm", "x/dtm_classification.npz"))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_D03_B07_implicit_확정이면_평활_DTM(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
assert road_dem(models, IMPLICIT, "design")[1].name == "dtm_classification_smooth.npz"
|
||||
dtm = {**IMPLICIT, "method": "dtm"}
|
||||
assert road_dem(models, dtm, "design")[1].name == "dtm_classification.npz" # 전과 같음
|
||||
assert road_dem(models, {**dtm, "smooth": True}, "design")[1].name.endswith("_smooth.npz")
|
||||
|
||||
|
||||
def test_B07_음영_implicit_확정이면_평활_DTM(tmp_path, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin
|
||||
|
||||
_project(tmp_path)
|
||||
opened: list[str] = []
|
||||
real_load = np.load
|
||||
monkeypatch.setattr(
|
||||
np, "load", lambda path, **kw: opened.append(Path(path).name) or real_load(path, **kw)
|
||||
)
|
||||
box = (0.0, 0.0, 39.0, 39.0)
|
||||
for params, name in (
|
||||
(IMPLICIT, "dtm_classification_smooth.npz"),
|
||||
({**IMPLICIT, "method": "dtm"}, "dtm_classification.npz"), # 전과 같음
|
||||
):
|
||||
basin._hillshade_for_box(SimpleNamespace(surface_params=params, project_root=tmp_path), box)
|
||||
assert opened[-1] == name
|
||||
|
||||
|
||||
def test_643_등고선_굳힌_격자_그대로(tmp_path):
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Contour import extract_contours
|
||||
|
||||
models = _project(tmp_path)
|
||||
contours = extract_contours(
|
||||
models / "implicit_classification.npz", "local_rbf_height_field", 1.0
|
||||
)
|
||||
points = np.array([p for c in contours for p in c["coordinates"]])
|
||||
sampler = build_surface_sampler(models, "classification", "implicit", False)
|
||||
z, valid = sampler.sample_xy(points[:, :2])
|
||||
assert valid.sum() > 0.9 * len(points)
|
||||
assert np.abs(z[valid] - points[valid, 2]).max() < 0.005 # 등고선 = 표본 면(좌표 mm 자리)
|
||||
@@ -44,6 +44,10 @@ def test_서고_사본_산근_목록에_듦_대가_단가_채움(world):
|
||||
by_key = {r["key"]: r for r in sg["sg_list"]}
|
||||
labor = compute_labor(daega, {**base, "gaps": []}, by_key)
|
||||
row = next(r for r in labor["daega"] if r["key"] == block["id"])
|
||||
assert not row["sum"] and row["blank"] == 3 # 723-4 서고 SC 안 줄 수량 없음 = 이 공사에서 입력
|
||||
cover = {block["cover"]: {ln["cid"]: {"qty": q} for ln, q in zip(block["lines"], (2, 1, 3))}}
|
||||
labor = compute_labor(daega, {**base, "gaps": []}, by_key, cover)
|
||||
row = next(r for r in labor["daega"] if r["key"] == block["id"])
|
||||
assert row["sum"] and not row.get("blank")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
- 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이
|
||||
원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 ·
|
||||
저장 다듬기 · 서고 사본 지킴(prune)
|
||||
- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 ·
|
||||
- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 문서 손(쓸 때 걷힘 · 723-1) ·
|
||||
품 · 단가 덮개 = 안 담음 수 ·
|
||||
같은 이름 409 → 덮어쓰기 · 새 이름
|
||||
"""
|
||||
|
||||
@@ -178,7 +179,8 @@ def test_자료집_같은_이름_물음(world):
|
||||
saved = json.loads(
|
||||
(world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9}
|
||||
# 723-1 = M02 SC 안 줄 수량 저장 안 함(덮어쓴 손 9 도 걷힘 · 쓰는 곳 알약 · 이 공사 입력으로 이음)
|
||||
assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] is None
|
||||
assert saved["담은로직"][0]["입력값"] == b145.STONE
|
||||
assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2
|
||||
|
||||
@@ -212,5 +214,5 @@ def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch):
|
||||
over = client.post(url, json={**body, "overwrite": first.json()["키"]})
|
||||
assert over.status_code == 200 and over.json()["키"] == first.json()["키"]
|
||||
saved = world["folder"] / "precombo" / f"{first.json()['키']}.json"
|
||||
assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4}
|
||||
assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] is None # 723-1
|
||||
assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404
|
||||
|
||||
@@ -32,8 +32,12 @@ def _sc(folder, lines, unit="㎡"):
|
||||
|
||||
|
||||
def _value(folder, key):
|
||||
"""안 줄 수량 = 모두 1(723-1 · 저장 안 함 → 쓰는 쪽 알약 값)."""
|
||||
ref = {**MINE, "키": key}
|
||||
with store.at(folder):
|
||||
return refs.Expand(_whole())({**MINE, "키": key})
|
||||
lines = refs.read_ref(ref)["담은로직"]
|
||||
given = {f"{refs.ref_id(ref)}#{i}": 1 for i in range(len(lines))}
|
||||
return refs.Expand(_whole(), given=given)(ref)
|
||||
|
||||
|
||||
def test_logic_line_cover_in_precombo(world):
|
||||
|
||||
@@ -27,20 +27,21 @@ def test_sk_list_layer_order_and_ref_shape():
|
||||
order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1)
|
||||
assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"]
|
||||
assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick
|
||||
assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick
|
||||
assert "r.종류 === kind" in pick and 'SANGEUN_KIND = "sangeun"' in pick
|
||||
assert "listLayerDocs(scopes, SANGEUN_KIND)" in pick
|
||||
assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick
|
||||
labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조
|
||||
assert 'if (tab === "sk") return findSangeun(q);' in labor
|
||||
# 721-2 — SK · 일위대가 탭은 찾아 넣기 창(Find_Pick) · 줄 누름 = 참조
|
||||
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
|
||||
assert "go: take(() => opt.ref?.(x.ref, x.제목 || x.이름))" in find
|
||||
assert "sangeun: true," in _text(
|
||||
M02 / "M02_MasterTemplete_Hopyo_Pick.ts"
|
||||
) # 일위대가 [로직 추가]
|
||||
) # 일위대가 [로직 추가] → 찾아 넣기 창
|
||||
store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts")
|
||||
assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts
|
||||
# 더한줄 고르기 — 715 이음 꼴 셋
|
||||
assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick
|
||||
assert "done({ 참조: ref, 수량: 1 })" in pick
|
||||
assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick
|
||||
# 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키)
|
||||
assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick
|
||||
assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick
|
||||
assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick
|
||||
|
||||
|
||||
def _sk(folder, name):
|
||||
|
||||
@@ -93,15 +93,24 @@ def test_B09_서고_사본_값_같음_원본판(world, tmp_path):
|
||||
"precombo",
|
||||
{"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]},
|
||||
)["이름"]
|
||||
_, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1)
|
||||
# 723-1 = SC 안 줄 수량(손 2) 저장 안 함 → 쓰는 쪽 알약 값으로 이음(층 · 사본 같은 값)
|
||||
pill = f"{refs.ref_id({**MINE, '키': sc})}#0"
|
||||
with store.at(world["personal"]):
|
||||
top = labor.clean(
|
||||
{"열": "rv", "담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}
|
||||
)
|
||||
live = labor.calc(top, {pill: 2})["줄"][0]
|
||||
area = {"시트": [{"id": "s", "칸": {"A1": {"값": 2}}, "연결": [{"칸": "A1", "키": pill}]}]}
|
||||
before = _print(world["personal"])
|
||||
root = tmp_path / "proj"
|
||||
made = library.import_copy(root, "precombo", "personal", "7/42", sc)
|
||||
assert made["만듦"] and _print(world["personal"]) == before
|
||||
folder = library.library_dir(root) / made["폴더"]
|
||||
read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")}
|
||||
got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0]
|
||||
assert not got["까닭"]
|
||||
bare = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0]
|
||||
assert bare["기준"] is None and "수량 없음" in bare["까닭"] # 이은 값 없음 = 1 로 안 셈
|
||||
got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, area)["줄"][0]
|
||||
assert not got["까닭"] and not live["까닭"]
|
||||
assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9)
|
||||
mark = read["manifest.json"]["원본판"]
|
||||
built = library._build("precombo", "personal", "7/42", sc)[0]
|
||||
|
||||
@@ -21,7 +21,7 @@ from B09_Estimation import B09_Estimation_Refresh as refresh
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import ASK, daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from M02_MasterTemplete import M02_Item_Copy as item_copy
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
@@ -122,15 +122,46 @@ def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path):
|
||||
if not _engine_ready(world):
|
||||
pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함")
|
||||
key = _mine(world, full=True)
|
||||
live = _calc(world["personal"], key)
|
||||
# 723-1 = 속 일위대가 안 줄 수량 저장 안 함 → M02 SK = 수량 없음 · B09 = 그 줄만 빈 줄(이 공사 입력)
|
||||
assert "수량 없음" in _calc(world["personal"], key)["까닭"]
|
||||
root, folder = _lib(world, tmp_path, key)
|
||||
daega, done, _ = _b09(root)
|
||||
block = done[daega["libs"][folder]]
|
||||
lines = block["lines"]
|
||||
assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000)
|
||||
sc_line = next(ln for ln in lines if ln["kind"] == "daega")
|
||||
assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2
|
||||
_same_money(block, live)
|
||||
sc = done[sc_line["key"]]
|
||||
assert sc["kind"] == "사전조합" and sc_line["qty"] == 2
|
||||
assert sc["blank"] == 1 and sc["lines"][0]["why"] == ASK and block["blank"] >= 1
|
||||
# 이 공사에서 입력(702 덮개 qty 1) = 옛 손 1 과 같은 값 → M02 셈(알약 값 1) 과 같은 금액
|
||||
cover = {sc["cover"]: {sc["lines"][0]["cid"]: {"qty": 1}}}
|
||||
block = _b09(root, cover)[1][daega["libs"][folder]]
|
||||
assert block["blank"] == 0
|
||||
sc_ref = next(x for x in _added(world, key) if "참조" in x)["참조"]
|
||||
_same_money(block, _calc_given(world["personal"], key, {f"{refs.ref_id(sc_ref)}#0": 1}))
|
||||
|
||||
|
||||
def _added(world, key):
|
||||
with store.at(world["personal"]):
|
||||
return store.read("sangeun", key)["문서"]["더한줄"]
|
||||
|
||||
|
||||
def _calc_given(folder, key, pills):
|
||||
"""M02 SK 셈 — 속 일위대가 안 줄 수량 = 알약 값(723-1 `Expand` given)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
with store.at(folder):
|
||||
clean = sd.clean(store.read("sangeun", key)["문서"], None, whole)
|
||||
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
|
||||
got = calc.run(
|
||||
clean, whole, None, refs.read_ref, Expand(whole, refs.read_ref, pills), (me,)
|
||||
)
|
||||
assert not got.get("까닭")
|
||||
return got
|
||||
|
||||
|
||||
def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""M02 폴더 · 층 · 단가산출 이름(PLAN 720 · 내역 16) — 임시 storage + 화면 글 대조.
|
||||
|
||||
720-1 단가산출 · 일위대가 층 단추 = 저장 안 한 고침 물음 → 그 층 목록표 ·
|
||||
720-2 최상위 폴더 = 메뉴 줄 · 층 줄 「+」 같은 `addFolder([])` ·
|
||||
720-3 문서 [이름 바꾸기] = 읽고 `이름` 만 갈아 저장(층 목록 제목 새 이름 · 다른 칸 그대로) ·
|
||||
720-4 구조물 조합 층 = 제자리 바꿈(해시 이동 없음).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from resources.tester.test_m02_precombo_folders import client # noqa: F401 — 같은 임시 층
|
||||
|
||||
M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
|
||||
ITEM = "/api/m02/layers/personal/templates/{}/{}"
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rows(c: TestClient, kind: str) -> dict[str, dict]:
|
||||
rows = c.get("/api/m02/layers/personal/templates").json()["양식"]
|
||||
return {r["이름"]: r for r in rows if r["종류"] == kind}
|
||||
|
||||
|
||||
def _rename(c: TestClient, kind: str, key: str, name: str) -> None:
|
||||
"""화면 `renameDoc` 과 같은 길 — 읽고 `이름` 만 갈아 같은 판으로 저장"""
|
||||
got = c.get(ITEM.format(kind, key)).json()
|
||||
body = {"판": got.get("판") or "", "문서": {**got["문서"], "이름": name}}
|
||||
res = c.put(ITEM.format(kind, key), json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
def test_일위대가_이름_바꾸기는_이름만(client: TestClient): # noqa: F811
|
||||
before = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
_rename(client, "precombo", "SC-000002", "나 고침")
|
||||
after = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
assert after["이름"] == "나 고침"
|
||||
assert {k: v for k, v in after.items() if k != "이름"} == {
|
||||
k: v for k, v in before.items() if k != "이름"
|
||||
}
|
||||
rows = _rows(client, "precombo")
|
||||
assert rows["SC-000002"]["제목"] == "나 고침" and rows["SC-000001"]["제목"] == "가"
|
||||
|
||||
|
||||
def test_단가산출_이름_바꾸기_목록_제목(client: TestClient): # noqa: F811
|
||||
body = {"문서": {"이름": "갑", "단위": "㎡", "바탕": None, "더한줄": []}}
|
||||
res = client.post("/api/m02/layers/personal/library/sangeun", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["이름"]
|
||||
_rename(client, "sangeun", key, "을")
|
||||
assert _rows(client, "sangeun")[key]["제목"] == "을"
|
||||
assert client.get(ITEM.format("sangeun", key)).json()["문서"]["단위"] == "㎡"
|
||||
|
||||
|
||||
def test_화면_층_단추_최상위_폴더_이름_바꾸기():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
lib = _text("M02_MasterTemplete_UI_Side_Library.ts")
|
||||
# 720-1 — 다른 층 단추 = 물음 뒤 목록표
|
||||
assert "setPre(next), void main.open(precomboList(pre))" in page
|
||||
assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0]
|
||||
# 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([])
|
||||
assert page.count(" add,\n") == 3 # 구조물 조합 · 일위대가 · 단가산출
|
||||
assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib
|
||||
assert "add.hidden = !at.canWrite;" in lib and "opt.bar(addTools)" in lib
|
||||
assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([])' in lib # 문서 줄
|
||||
tree = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }' in tree
|
||||
# 720-3 — 문서 줄 [이름 바꾸기] · 저장 뒤 머리 제목
|
||||
assert "run: () => void renameDoc(row)" in lib
|
||||
assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main
|
||||
|
||||
|
||||
def test_구조물_조합_층_제자리_바꿈():
|
||||
"""720-4 — 해시 이동 없음 · replaceState · 나무 · 층 줄 · 띠를 감싼 칸에 갈아 끼움 · 권한 = 함수 ·
|
||||
도면 양식 = 누구나(단추는 관리자) · 실무문서 = 시스템 관리자 · 불러오기 장부 = 열 때 층 · 권한."""
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert "navigateTo" not in page
|
||||
switch = page.split("const switchStructure = async")[1].split("const go =")[0]
|
||||
assert "history.replaceState(" in switch and "build();" in switch
|
||||
assert "await main.openTable(layer, null, true);" in switch
|
||||
assert "if (mine !== turn) return;" in switch # 늦게 온 옛 고름 버림
|
||||
build = page.split("const build = (): void => {")[1].split("const settleLayer")[0]
|
||||
assert "topHost.replaceChildren(" in build and "treeHost.replaceChildren(" in build
|
||||
assert "tree: [treeHost]," in page and "top: topHost," in page
|
||||
assert "() => isAdmin," in page and 'isSystem: user?.role === "SYSTEM_ADMIN",' in page
|
||||
assert "main.root.isConnected ? void afterItem()" in page # 나무를 갈아도 듣기 그대로
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert "const drawing = section(" in side and "const practice = opt.isSystem" in side
|
||||
# 도면 양식 칸 = 누구나 · 단추만 관리자(720-4 덧)
|
||||
assert "const drawing = opt.isSystem" not in side
|
||||
assert 'if (!opt.isSystem) return [];' in side
|
||||
assert "items = await listTemplates(layer, null);" in side
|
||||
hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo
|
||||
@@ -0,0 +1,106 @@
|
||||
"""721 M02 찾아 넣기 창 — 단추 이름 · 자리 · 부르는 곳 · 인력 · 재료 줄 꼴(요구서_내역16 1장 5~7 · 2장 1 · 2 · 8).
|
||||
|
||||
- 721-1 단가산출 상세 = [기본 로직 선택]이 둘째 줄 [항목 추가] 왼쪽
|
||||
- 721-2 M02 세 곳(기본 로직 선택 · 항목 추가 · 일위대가 [로직 추가])만 새 창 · B09 · 불러오기는 옛 창
|
||||
- M01 전체 = 로직 · 인력(job) · 재료(price) · 기계 · 실무 호표 없음
|
||||
- 고른 인력 · 재료 = 단가산출 더한줄 `{종류, 요소}` · 일위대가 고정 줄(705-1 이음 꼴) — 둘 다 서버가 받아 노임 · 자재 값으로 셈
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
LB, MT = "LB000033", "MT000018" # 석공 · 합판(내수) — M01 에 있음
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_721_1_단추_이름과_자리():
|
||||
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "children: [views.root, pick, addButton, baseHint]" in detail
|
||||
assert " pick,\n );" not in detail # 머리 줄에서 뺌
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert 'M02_SangeunPickBase: ["기본 로직 선택"' in locale
|
||||
assert 'M02_SangeunAddLine: ["항목 추가"' in locale
|
||||
|
||||
|
||||
def test_721_2_부르는_곳_셋만_새_창():
|
||||
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
|
||||
labor_pick = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert 'if (lib?.sangeun && lib.layer !== "project")' in labor_pick
|
||||
assert "return openFindPicker({" in labor_pick
|
||||
assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음
|
||||
assert "logicOnly: true," in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts")
|
||||
assert "sangeun: true," in _text(M02 / "M02_MasterTemplete_Hopyo_Pick.ts") # 일위대가
|
||||
# 옛 창 그대로 — B09 · 불러오기는 sangeun 없이 부름
|
||||
for f in (
|
||||
ROOT / "B09_Estimation" / "B09_Estimation_UI_Link.ts",
|
||||
ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts",
|
||||
M02 / "M02_MasterTemplete_Hopyo_Choose.ts",
|
||||
M02 / "M02_MasterTemplete_Hopyo_Ledger.ts",
|
||||
):
|
||||
text = _text(f)
|
||||
assert "openLogicPicker(" in text and "sangeun:" not in text, f.name
|
||||
# 탭 셋 · 찾기 칸 하나 · 거름 고르기 없음 · M01 = 로직 · 인력 · 재료(기계 · 호표 없음)
|
||||
for key in ('key: "m01"', 'key: "sk"', 'key: "pre"'):
|
||||
assert key in find
|
||||
assert "createSelectField" not in find and "Hopyo" not in find
|
||||
assert '(kind: "job" | "price")' in find and "/elements" not in find
|
||||
|
||||
|
||||
def test_721_2_고정_줄_꼴():
|
||||
find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts")
|
||||
assert "이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }" in find
|
||||
assert "go: put ? take(() => put(e))" in find
|
||||
assert "dim: !put," in find # 받는 쪽 없으면 회색 막힘
|
||||
|
||||
|
||||
def _fx(name: str, unit: str, code: str) -> dict:
|
||||
"""`fixedOf` 꼴 — 인력 = 노임 이음 · 재료 = 자재 이음 · 단가 = 고를 때 값(셈은 단가 책)"""
|
||||
labor_ = code.startswith("LB")
|
||||
return {
|
||||
"이름": name,
|
||||
"규격": "",
|
||||
"단위": unit,
|
||||
"단가": {"노무비": 1 if labor_ else 0, "재료비": 0 if labor_ else 1, "경비": 0},
|
||||
"이음": {"노임": code if labor_ else None, "자재": None if labor_ else code, "기계": None},
|
||||
}
|
||||
|
||||
|
||||
def test_721_2_인력_재료_서버가_받아_셈():
|
||||
whole = labor.m01.loaded()[1]
|
||||
wage = whole.get(LB)["값"]
|
||||
sk = calc.calc_doc(
|
||||
{
|
||||
"키": "SK-000001",
|
||||
"이름": "시험",
|
||||
"단위": "㎡",
|
||||
"바탕": None,
|
||||
"더한줄": [
|
||||
{"종류": "인력", "요소": LB, "이름": "석공", "단위": "인", "수량": 1},
|
||||
{"종류": "재료", "요소": MT, "이름": "합판", "단위": "㎡", "수량": 1},
|
||||
],
|
||||
}
|
||||
)["1단위"]
|
||||
doc = labor.clean(
|
||||
{
|
||||
"담은로직": [
|
||||
{"로직": "", "고정": _fx("석공", "인", LB), "수량": {"손": 1}},
|
||||
{"로직": "", "고정": _fx("합판", "㎡", MT), "수량": {"손": 1}},
|
||||
]
|
||||
}
|
||||
)
|
||||
got = labor.calc(doc, {})
|
||||
assert sk["노무비"] == pytest.approx(wage) and sk["재료비"] > 0
|
||||
assert got["반영합"]["계"] == pytest.approx(sk["계"])
|
||||
assert [ln["이은"] for ln in got["줄"]] == [["노무비"], ["재료비"]]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""722 M01 단가산출 상세 개편 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 722-1 공용 단가산출 상세(`M01_MasterData_UI_LogicLab_Ho.ts`) — 하위 박스(서버 묶음) · 장비 한 줄 + 펼침 · 계산식 열 · 박스 옵션
|
||||
- 722-2 그 자리 고침(`M01_MasterData_UI_LogicLab_Cover.ts`) — M01 = 시험 값(줄 사본) · 정본 · 캐시 안 바뀜
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M01 = ROOT / "M01_MasterData"
|
||||
HO = M01 / "M01_MasterData_UI_LogicLab_Ho.ts"
|
||||
COVER = M01 / "M01_MasterData_UI_LogicLab_Cover.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _pure(p: Path) -> str:
|
||||
s = _text(p)
|
||||
return s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
|
||||
|
||||
def _node(code: str):
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", code],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_상세가_공용_부품을_씀():
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail
|
||||
ho = _text(HO)
|
||||
# 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식)
|
||||
for part in (
|
||||
"tableShell(",
|
||||
"tableRow(",
|
||||
"createCard(",
|
||||
"varRow(",
|
||||
"inputChip(",
|
||||
"sheetFormula(",
|
||||
):
|
||||
assert part in ho, part
|
||||
for s in (ho, _text(COVER)):
|
||||
assert 'el("table"' not in s and 'el("button"' not in s and 'el("input"' not in s
|
||||
assert "document.createElement" not in s and "sessionStorage" not in s
|
||||
assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts")
|
||||
for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"):
|
||||
assert len(_text(p).splitlines()) <= (
|
||||
627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700
|
||||
)
|
||||
|
||||
|
||||
def test_시험_고침은_캐시_밖():
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
|
||||
assert ": trial.apply(row);" in lab
|
||||
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
|
||||
assert "onTrial: drawCalc," in lab
|
||||
cover = _text(COVER)
|
||||
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
|
||||
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
assert "...(target.edit ? [target.edit(close)] : [])" in modal
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_박스_묶기():
|
||||
body = """
|
||||
const e = (idx, bundle, merge, amount) => ({ idx, bundle, merge, amount });
|
||||
const got = boxesOf([
|
||||
e(0, "인력", false, 10), e(1, "경비", true, 100), e(1, "경비", true, 20), e(1, "경비", true, 30),
|
||||
e(2, "하위", true, 5), e(2, "하위", true, null), e(3, "재료", false, null),
|
||||
], ["인력", "재료", "경비"]).map((b) => ({ 묶음: b.묶음, 소계: b.소계,
|
||||
줄: b.줄.map((r) => "몫" in r ? [r.idx, r.금액, r.몫.length] : r.idx) }));
|
||||
console.log(JSON.stringify(got));
|
||||
"""
|
||||
got = _node(_pure(HO) + "\n" + body)
|
||||
assert [b["묶음"] for b in got] == ["인력", "재료", "경비", "하위"]
|
||||
assert got[0] == {"묶음": "인력", "소계": 10, "줄": [0]}
|
||||
assert got[1] == {"묶음": "재료", "소계": 0, "줄": [3]}
|
||||
assert got[2] == {"묶음": "경비", "소계": 150, "줄": [[1, 150, 3]]} # 장비 한 줄 = 몫 셋 합
|
||||
assert got[3]["줄"] == [[2, 5, 2]]
|
||||
empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))')
|
||||
assert empty == [{"묶음": "인력", "줄": [], "소계": 0}]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_고침_덮기():
|
||||
body = """
|
||||
const row = { 키: "GF1", 호표: [
|
||||
{ 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" },
|
||||
{ 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } },
|
||||
{ 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] };
|
||||
const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } });
|
||||
console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] }));
|
||||
"""
|
||||
got = _node(_pure(COVER) + "\n" + body)
|
||||
out = got["out"]
|
||||
assert (
|
||||
out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000
|
||||
) # 지수 꼴 없음 · 손 단가
|
||||
assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1]
|
||||
assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로
|
||||
assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""723-4 B09 공사 사본의 일위대가(SC) 안 줄 수량 = 프로젝트 값 연결.
|
||||
|
||||
- 723-1 = M02 일위대가(SC) 안 줄 · 자재 수량 저장 안 함(null = 수량 없음 · 1 아님)
|
||||
- 구조물 조합에서 온 SC = 그 사본 면적 알약 값(「참조 id#차례」 · 「참조 id#자재|코드」)으로 굳힘
|
||||
- 내역에서 바로 쓴 SC(서고 사본) = 빈 줄 `ask` → 이 공사에서 입력(702 덮개 qty) → 금액
|
||||
- 옛 손(B09 굳힌 사본) = 그대로 · 덮개 열쇠 `C:참조` 그대로(기존 공사 금액 · 덮개 같음) ·
|
||||
알약 값 쓴 블록만 `C:참조|S:사본`(사본마다 값 다름 · 이 공사 입력 덮개와 안 섞임)
|
||||
- 셈 짝(`B09_Estimation_Calc_Labor` .py ↔ .ts) 거울
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import ASK, Builder # noqa: E402
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"b145", Path(__file__).with_name("test_145_2_b09_labor_calc.py")
|
||||
)
|
||||
b145 = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(b145)
|
||||
|
||||
REF = {"층": "project", "주인": "t", "키": "SC-000001"}
|
||||
RID = refs.ref_id(REF)
|
||||
MAT = "MT00001"
|
||||
SHEETS = {"labor": [], "material": [{"key": MAT, "price": 500}], "machine": []}
|
||||
|
||||
|
||||
def _fix(name, labor):
|
||||
return {"고정": {"이름": name, "단위": "식", "단가": {"노무비": labor}}}
|
||||
|
||||
|
||||
def _doc(hand=None, mat=None):
|
||||
"""SC 한 벌 — 고정 줄 둘(노무 1000 · 2000) + 자재 하나 · 손 = hand · mat(None = 수량 없음)."""
|
||||
q = (lambda v: {"수량": {"손": v}}) if hand is not None else (lambda v: {"수량": None})
|
||||
return {
|
||||
"이름": "시험 SC",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{**_fix("갑", 1000), **q(hand)}, {**_fix("을", 2000), **q(hand)}],
|
||||
"자재": [{"코드": MAT, "수량": {"손": mat} if mat is not None else None}],
|
||||
}
|
||||
|
||||
|
||||
def _build(doc, use=({}, "")):
|
||||
build = Builder()
|
||||
whole = {MAT: {"이름": "철선", "규격": "#8", "단위": "kg"}} # 사본 단가 대신(_material 만 읽음)
|
||||
bid = build.precombo(whole, REF, doc, {}, 1, use)
|
||||
block = build.blocks[bid]
|
||||
return {"blocks": [block], "owners": {"pipe@100.0": bid}}, block
|
||||
|
||||
|
||||
def test_수량_없는_SC_서고_사본_경고():
|
||||
daega, block = _build(_doc(), ({}, "L:lib"))
|
||||
assert [ln["qty"] for ln in block["lines"]] == [None, None, None]
|
||||
assert all(ln["ask"] == ASK for ln in block["lines"])
|
||||
assert block["cover"] == f"C:{RID}" # 공사 안 한 덮개(703-1 지난 공사 가져옴 꼴 그대로)
|
||||
out = compute_labor(daega, SHEETS)
|
||||
got = out["daega_blocks"][0]
|
||||
assert got["blank"] == 3 and got["sum"] == 0
|
||||
assert [ln["why"] for ln in got["lines"]] == [ASK] * 3
|
||||
assert sum(g["why"] == ASK for g in out["gaps"]) == 3
|
||||
assert out["daega"][0]["blank"] == 3 # 내역 · 원가계산서 빈 줄(166-6 · 701-3)로 넘어감
|
||||
|
||||
|
||||
def test_이_공사에서_입력_금액():
|
||||
daega, block = _build(_doc(), ({}, "L:lib"))
|
||||
cids = [ln["cid"] for ln in block["lines"]]
|
||||
cover = {block["cover"]: {cids[0]: {"qty": 2}, cids[1]: {"qty": 0.5}, cids[2]: {"qty": 3}}}
|
||||
got = compute_labor(daega, SHEETS, None, cover)["daega_blocks"][0]
|
||||
assert [ln["amount"] for ln in got["lines"]] == [2000.0, 1000.0, 1500.0]
|
||||
assert got["blank"] == 0 and got["sum"] == 4500.0
|
||||
assert not any("why" in ln for ln in got["lines"]) # 입력 뒤 경고 글 없음
|
||||
|
||||
|
||||
def test_구조물_조합_알약_값_굳힘():
|
||||
given = {f"{RID}#0": 1.5, f"{RID}#1": 2, f"{RID}#자재|{MAT}": 4}
|
||||
daega, block = _build(_doc(), (given, "S:pipe@100.0"))
|
||||
assert [ln["qty"] for ln in block["lines"]] == [1.5, 2.0, 4.0]
|
||||
assert not any("ask" in ln for ln in block["lines"])
|
||||
assert block["cover"] == f"C:{RID}|S:pipe@100.0"
|
||||
got = compute_labor(daega, SHEETS)["daega_blocks"][0]
|
||||
assert got["sum"] == 1500.0 + 4000.0 + 2000.0 and got["blank"] == 0
|
||||
|
||||
|
||||
def test_옛_손_그대로():
|
||||
daega, block = _build(_doc(hand=3, mat=2), ({}, "S:pipe@100.0"))
|
||||
assert [ln["qty"] for ln in block["lines"]] == [3.0, 3.0, 2.0]
|
||||
assert block["cover"] == f"C:{RID}" # 옛 덮개 열쇠 그대로
|
||||
got = compute_labor(daega, SHEETS)["daega_blocks"][0]
|
||||
assert got["sum"] == 3000.0 + 6000.0 + 1000.0
|
||||
|
||||
|
||||
def test_거울_파이썬_TS():
|
||||
daega, block = _build(_doc(), ({}, "L:lib"))
|
||||
cid = block["lines"][0]["cid"]
|
||||
for cover in ({}, {block["cover"]: {cid: {"qty": 2}}}):
|
||||
py = json.loads(json.dumps(compute_labor(daega, SHEETS, {}, cover), ensure_ascii=False))
|
||||
ts = b145._ts(daega, SHEETS, {}, cover)
|
||||
assert b145._same(py, ts["out"])
|
||||
|
||||
|
||||
def _structure(tmp_path, area):
|
||||
"""구조물 사본(참조 SC 한 줄 · 굳힌 SC = 수량 없음) + 면적.json(SC 안 줄 알약 연결 · 723-1)."""
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
|
||||
root = b145._project(tmp_path / "proj", [(100, "SS000901")])
|
||||
sc = {**_doc(), "자재": []}
|
||||
b145._snap(root, "pipe@100.0", "SS000901", [{"참조": REF, "수량": {"손": 1}}], {}, {RID: sc})
|
||||
folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0")
|
||||
if area is not None:
|
||||
(folder / "면적.json").write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8")
|
||||
return root, folder
|
||||
|
||||
|
||||
def _sc_block(root):
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
|
||||
got = daega_inputs(root)
|
||||
combo = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"])
|
||||
return got, next(b for b in got["blocks"] if b["id"] == combo["lines"][0]["key"])
|
||||
|
||||
|
||||
def test_구조물_사본_면적_알약_값_굳힘_설계_연동(tmp_path):
|
||||
links = [{"칸": "A1", "키": f"{RID}#0"}, {"칸": "A2", "키": f"{RID}#1"}]
|
||||
area = {"시트": [{"id": "s", "칸": {"A1": {"값": 1.5}, "A2": {"값": 2}}, "연결": links}]}
|
||||
root, folder = _structure(tmp_path, area)
|
||||
got, block = _sc_block(root)
|
||||
assert [ln["qty"] for ln in block["lines"]] == [1.5, 2.0]
|
||||
assert block["cover"] == f"C:{RID}|S:{folder.name}" and not any(
|
||||
"ask" in ln for ln in block["lines"]
|
||||
)
|
||||
out = compute_labor(got, SHEETS)
|
||||
by_id = {b["id"]: b for b in out["daega_blocks"]}
|
||||
assert by_id[block["id"]]["sum"] == 1500.0 + 4000.0
|
||||
assert by_id[got["owners"]["pipe@100.0"]]["sum"] == 5500.0 # 조합 = SC 1 단위 × 1
|
||||
# 설계 연동 — 면적 값이 바뀌면 다시 셈(사본 면적.json)
|
||||
area["시트"][0]["칸"]["A1"]["값"] = 3
|
||||
(folder / "면적.json").write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8")
|
||||
assert [ln["qty"] for ln in _sc_block(root)[1]["lines"]] == [3.0, 2.0]
|
||||
|
||||
|
||||
def test_구조물_사본_알약_안_이으면_빈_줄(tmp_path):
|
||||
root, folder = _structure(tmp_path, None)
|
||||
got, block = _sc_block(root)
|
||||
assert [ln["qty"] for ln in block["lines"]] == [None, None]
|
||||
assert block["cover"] == f"C:{RID}" # 알약 안 씀 = 서고 SC 와 같은 「이 공사 입력」 덮개
|
||||
out = compute_labor(got, SHEETS)
|
||||
owner = next(r for r in out["daega"] if r["key"] == got["owners"]["pipe@100.0"])
|
||||
assert owner["blank"] >= 1 # 부른 SC 빈 줄 = 조합도 빈 줄(166-6)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""PLAN 723 — M02 일위대가(사전조합) 수량 = 저장 안 함 · 일위대가표만 · 줄 모달(내역 16 · 17).
|
||||
|
||||
723-1 서버 — 저장 때 안 줄 · 자재 수량 null(비율 줄 율만) · 펼침 = 쓰는 쪽 알약 값 · 안 이으면 「수량 없음」 ·
|
||||
구조물 조합 알약에 안 줄 알약 · 불러오기 엑셀 수량 = `본뜬.실무수량`(셈 안 씀).
|
||||
723-2 · 723-3 화면 — 서고 상세 = `Labor_Table`(수량 칸 휘발 임시 입력 · 우클릭) · 줄 모달 = 717-2b 상세 줄 모드.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
|
||||
|
||||
SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "M02_MasterTemplete")
|
||||
SYS = {"층": "system", "주인": None}
|
||||
STONE = "GF000219" # 원/㎡
|
||||
RATE = {"이름": "공구손료", "단위": "%", "단가": {"노무비": 0, "재료비": 0, "경비": 0}}
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
return open(os.path.join(SRC, name), encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_저장은_안_줄_수량을_걷고_비율_줄_율만_남김(world): # noqa: F811
|
||||
rate = {"고정": {**RATE, "비율": {"기준": "노무비", "비목": "경비"}}, "수량": {"손": 3}}
|
||||
lines = [{"로직": STONE, "수량": {"손": 2.5}}, rate]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "수량 없음", "단위": "m", "담은로직": lines})["이름"]
|
||||
doc = store.read("precombo", key)["문서"]
|
||||
assert doc["담은로직"][0]["수량"] is None
|
||||
assert doc["담은로직"][1]["수량"] == {"손": 3} # 비율 = 율(기준단위 당 수량 아님)
|
||||
frozen = refs.with_pills(doc) # 프로젝트 굳힘도 수량 없이
|
||||
assert frozen["담은로직"][0]["수량"] is None and frozen["알약"]
|
||||
|
||||
|
||||
def test_펼침은_이은_알약만_쓰고_층_원본_옛_손은_안_씀(world): # noqa: F811
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "옛", "단위": "m", "담은로직": [{"로직": STONE}]})[
|
||||
"이름"
|
||||
]
|
||||
ref = {**SYS, "키": key}
|
||||
path = world["system"] / "precombo" / f"{key}.json"
|
||||
import json
|
||||
|
||||
old = json.loads(path.read_bytes())
|
||||
old["담은로직"][0]["수량"] = {"손": 9} # 옛 문서(723-1 앞) — 파일은 그대로 둠
|
||||
path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8")
|
||||
whole = m01.loaded()[1]
|
||||
with store.at(world["system"]):
|
||||
bare = refs.Expand(whole)(ref)
|
||||
got = refs.Expand(whole, given={f"{refs.ref_id(ref)}#0": 2})(ref)
|
||||
frozen = refs.Expand(whole, read=lambda r: old)(ref) # 굳힌 사본 = 손 그대로
|
||||
stone = labor.run_line(whole, STONE, {})["기준"]["계"]
|
||||
assert bare["기준"] is None and "수량 없음" in bare["까닭"]
|
||||
assert got["기준"]["계"] == pytest.approx(stone * 2)
|
||||
assert frozen["기준"]["계"] == pytest.approx(stone * 9)
|
||||
|
||||
|
||||
def test_구조물_조합_알약에_안_줄_알약(world): # noqa: F811
|
||||
with store.at(world["system"]):
|
||||
key = refs.create(
|
||||
"precombo", {"이름": "옹벽", "단위": "m", "담은로직": [{"로직": STONE, "별칭": "돌"}]}
|
||||
)["이름"]
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}}]})
|
||||
pills = labor.pills(doc)
|
||||
rid = refs.ref_id({**SYS, "키": key})
|
||||
assert [p["키"] for p in pills] == [rid, f"{rid}#0"]
|
||||
assert pills[1]["이름"] == "옹벽 · 돌" and pills[1]["단위"] == "㎡"
|
||||
|
||||
|
||||
def test_화면_서고_상세는_일위대가표만_임시_수량_휘발():
|
||||
table, detail = (
|
||||
_src("M02_MasterTemplete_Labor_Table.ts"),
|
||||
_src("M02_MasterTemplete_Labor_Detail.ts"),
|
||||
)
|
||||
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
|
||||
assert "const temp = new Map<string, number>();" in table # 휘발 — 문서 밖
|
||||
assert "calc = calc && settle(calc, withTemp(), {});" in table # 짝 셈 미리 보기
|
||||
assert (
|
||||
'const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };' in table
|
||||
) # temp 안 실음
|
||||
for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'):
|
||||
assert label in table # 우클릭 메뉴
|
||||
assert "fixed: (fx: LaborFixed, name: string)" in table # 721-2 고정 줄 이음
|
||||
|
||||
|
||||
def test_화면_줄_모달은_SK_상세_줄_모드_적용은_덮개와_옵션():
|
||||
modal, sk = (
|
||||
_src("M02_MasterTemplete_Labor_RowModal.ts"),
|
||||
_src("M02_MasterTemplete_Labor_RowSk.ts"),
|
||||
)
|
||||
detail = _src("M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "mountSangeunDetail(box, doc," in sk and "getValues: () => ({ ...values })" in detail
|
||||
assert "if (got.patch) row.패치 = got.patch;" in modal and "setValues(got.values);" in modal
|
||||
assert 'createCheckbox(t("AlsoSk"), { checked: false })' in modal # 기본 꺼짐
|
||||
assert "openRowSangeun" not in modal # 717-3 두 보기 갈음
|
||||
@@ -106,7 +106,7 @@ def test_나무는_좌측_패널_찾기_칸_없음():
|
||||
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "createTreePanel({" in page and "tree.select(column)" in page
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
assert "layerBar(tree.add, () => void main.openTable(layer))," in page
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main
|
||||
assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목
|
||||
|
||||
@@ -87,7 +87,9 @@ def test_머리_고침은_쓸_수_있는_층만_나무로():
|
||||
assert "readOnly || !extras.onHeadRename" in main
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "tree.editFromTable(headRenameEdit(col, level, name), col.id)" in page
|
||||
assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0
|
||||
assert (
|
||||
"onHeadRename: (col: StructureColumn" in page
|
||||
) # 720-4 — 가림은 편집 칸 readOnly · 나무 canEdit
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree
|
||||
|
||||
|
||||
@@ -61,22 +61,24 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
|
||||
assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"]
|
||||
stone, *made, soil = doc["담은로직"] # 705-3 — 확인 안 한 줄 · 「우리에 없음」 = 고정 줄
|
||||
assert stone["고정"]["단가"] == {"노무비": 0, "재료비": 1400000, "경비": 0}
|
||||
assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] == {"손": 1}
|
||||
assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] == {"손": 0.09}
|
||||
hands = origin["실무수량"]["줄"] # 723-1 — 수량 = 저장 안 함 · 엑셀 수량은 출처 기록만
|
||||
assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] is None and hands["0"] == 1
|
||||
assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] is None
|
||||
assert hands[str(len(doc["담은로직"]) - 1)] == 0.09
|
||||
assert origin["품셈"] == [1, 2] and got["줄수"]["고정"] == 2 and got["빠진줄"] == []
|
||||
assert made == [
|
||||
{
|
||||
"로직": "GF000101",
|
||||
"입력값": {"지역": "서울"},
|
||||
"별칭": "구조물터파기(인력100%)",
|
||||
"수량": {"손": 0.14},
|
||||
"수량": None, # 723-1 — 엑셀 0.14 = 본뜬.실무수량
|
||||
"메모": "실무 0.14㎥ × 1 · 토사",
|
||||
},
|
||||
{
|
||||
"로직": "GF000113",
|
||||
"입력값": {},
|
||||
"별칭": "구조물되메우기(인력100%)",
|
||||
"수량": {"손": 0.1}, # 엑셀 0.05 × 배 2
|
||||
"수량": None, # 엑셀 0.05 × 배 2 = 본뜬.실무수량(723-1)
|
||||
"메모": "실무 0.05㎥ × 2",
|
||||
},
|
||||
]
|
||||
@@ -108,14 +110,20 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
|
||||
first, second, fx, wrapped = doc["담은로직"]
|
||||
assert fx["고정"]["출처"]["줄"] == 2 # 확인 안 한 줄 = 고정(705-3)
|
||||
assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭
|
||||
assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사"
|
||||
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
|
||||
hands = doc["본뜬"]["실무수량"]["줄"] # 723-1 — 저장 수량 없음 · 엑셀 수량 = 출처 기록
|
||||
assert second["수량"] is None and hands["1"] == 0.014
|
||||
assert second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사"
|
||||
assert (
|
||||
wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] is None and hands["3"] == 0.09
|
||||
)
|
||||
inner = _doc(mine, wrapped["참조"]["키"])
|
||||
assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄 GF000101"
|
||||
assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1}
|
||||
assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] is None
|
||||
assert inner["본뜬"]["실무수량"]["줄"] == {"0": 1}
|
||||
assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [
|
||||
("MT004258", {"손": 1}, "직접계산")
|
||||
("MT004258", None, "직접계산")
|
||||
]
|
||||
assert doc["본뜬"]["실무수량"]["자재"] == {"MT004258": 1}
|
||||
stone = next(v for k, v in _book(mine).items() if k.startswith("임도표지석(받침"))
|
||||
assert stone["짝"][0]["자재"] == "MT004258"
|
||||
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
|
||||
@@ -232,14 +240,15 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
|
||||
assert rate["비율"] == {"기준": "재료비", "비목": "재료비"}
|
||||
got = _build(client, 45, ref)
|
||||
mine = layers.personal_dir(7, 42)
|
||||
line = _doc(mine, got["참조"]["키"])["담은로직"][0]
|
||||
made = _doc(mine, got["참조"]["키"])
|
||||
line = made["담은로직"][0]
|
||||
assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비"
|
||||
assert line["수량"] == {"손": 2.3}
|
||||
assert line["수량"] is None and made["본뜬"]["실무수량"]["줄"]["0"] == 2.3 # 723-1
|
||||
assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀
|
||||
whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]})
|
||||
doc = _doc(mine, whole["참조"]["키"])
|
||||
assert [x["로직"] for x in doc["담은로직"]] == ["GF000101"]
|
||||
assert doc["담은로직"][0]["수량"] == {"손": 1}
|
||||
assert doc["담은로직"][0]["수량"] is None and doc["본뜬"]["실무수량"]["줄"] == {"0": 1}
|
||||
assert {x["까닭"] for x in whole["빠진줄"]} == {"호표 통째로 대신"}
|
||||
head = next(v for k, v in _book(mine).items() if k.startswith("호표|임도표지석|"))
|
||||
assert head["짝"][0]["로직"] == "GF000101" and head["원래"]["품명"] == "임도표지석"
|
||||
|
||||
@@ -128,8 +128,10 @@ def test_섞인_줄_몫_배(client):
|
||||
assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"]
|
||||
one = {k: part[k] for k in ("로직", "입력값", "배", "몫")}
|
||||
made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}])
|
||||
row = next(x for x in _doc(made["참조"])["담은로직"] if x.get("로직") == "GF000211")
|
||||
assert row["수량"]["손"] == round(line["수량"] * 0.1, 6)
|
||||
doc = _doc(made["참조"])
|
||||
at = next(i for i, x in enumerate(doc["담은로직"]) if x.get("로직") == "GF000211")
|
||||
assert doc["담은로직"][at]["수량"] is None # 723-1 — 수량 저장 안 함 · 엑셀 수량 = 출처 기록
|
||||
assert doc["본뜬"]["실무수량"]["줄"][str(at)] == round(line["수량"] * 0.1, 6)
|
||||
|
||||
|
||||
def test_막는_길(client):
|
||||
|
||||
@@ -342,7 +342,7 @@ def test_좌측_판_자리와_권한():
|
||||
order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),")
|
||||
assert [host.index(k) for k in order] == sorted(host.index(k) for k in order)
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "canWrite: opt.canWrite," in side # 구조물 조합 층 쓰기 권한(720-4 함수)
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
|
||||
@@ -351,7 +351,9 @@ def test_좌측_판_자리와_권한():
|
||||
assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start
|
||||
assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side
|
||||
assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side
|
||||
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
|
||||
assert (
|
||||
"buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo_side
|
||||
) # [별칭 장부]
|
||||
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
|
||||
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
|
||||
|
||||
@@ -73,6 +73,13 @@ def _combo(folder, lines, name="조합"):
|
||||
return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _area(given):
|
||||
"""면적산출 사본 꼴 — 알약 키마다 칸 하나(723-1 · 안 줄 수량 = 이은 칸 값)."""
|
||||
cells = {f"A{i}": {"값": v} for i, v in enumerate(given.values(), 1)}
|
||||
links = [{"칸": f"A{i}", "키": k} for i, k in enumerate(given, 1)]
|
||||
return {"시트": [{"id": "s", "칸": cells, "연결": links}]}
|
||||
|
||||
|
||||
def _read(folder, kind, key):
|
||||
return json.loads((folder / kind / f"{key}.json").read_bytes())
|
||||
|
||||
@@ -221,7 +228,9 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합
|
||||
ref = {**SYS, "키": sc}
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]})
|
||||
got = labor.calc(doc, {})
|
||||
bare = labor.calc(doc, {})["줄"][0] # 723-1 — 안 줄 손은 저장 안 함 · 안 이으면 수량 없음
|
||||
got = labor.calc(doc, {f"{refs.ref_id(ref)}#0": 2}) # 안 줄 알약 = 구조물 조합 칸 값
|
||||
assert bare["기준"] is None and "수량 없음" in bare["까닭"] and bare["안"][0]["수량"] is None
|
||||
one = got["줄"][0]
|
||||
want = Decimal(str(_base(TEN)["계"])) / 10 * 2
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
@@ -233,7 +242,9 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp
|
||||
pills = labor.pills(doc)
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}]
|
||||
assert pills[0] == {"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}
|
||||
assert [p["키"] for p in pills[1:]] == [f"{refs.ref_id(ref)}#0"] # 안 줄 알약(723-1)
|
||||
assert pills[1]["이름"].startswith("둘 · ")
|
||||
|
||||
|
||||
def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world):
|
||||
@@ -386,9 +397,10 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
|
||||
sp = _promote(world["system"])
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}])
|
||||
ref = {**SYS, "키": sc}
|
||||
given = {f"{refs.ref_id(ref)}#0": 2} # 723-1 — 안 줄 수량 = 이은 칸 값
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]})
|
||||
live = labor.calc(doc, {})["줄"][0]["기준"]["계"]
|
||||
live = labor.calc(doc, given)["줄"][0]["기준"]["계"]
|
||||
rows = source.labor_doc_rows(doc)
|
||||
assert set(rows["참조사본"]) == {refs.ref_id(ref), refs.ref_id({**SYS, "키": sp})}
|
||||
assert refs.frozen_logics(rows["참조사본"]) == [TEN]
|
||||
@@ -396,7 +408,7 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
|
||||
for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄
|
||||
kind = refs.kind_of(key)
|
||||
(world["system"] / kind / f"{key}.json").unlink()
|
||||
got = prices.calc(rows, bundle, {}, {}, None)
|
||||
got = prices.calc(rows, bundle, {}, {}, _area(given))
|
||||
line = got["줄"][0]
|
||||
assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1)
|
||||
assert line["기준"]["계"] == pytest.approx(live, rel=1e-9)
|
||||
@@ -438,8 +450,8 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world):
|
||||
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋")
|
||||
rows = [{"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}]
|
||||
refs.summaries(world["system"], rows)
|
||||
one = float(Decimal(str(_base(TEN)["계"])) / 10)
|
||||
assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", pytest.approx(one * 3))
|
||||
# 723-1 — 안 줄 수량 저장 안 함 → 목록 기준금액 없음(쓰는 쪽 알약으로만 값)
|
||||
assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", None)
|
||||
assert "기준금액" not in rows[1]
|
||||
|
||||
|
||||
@@ -456,16 +468,17 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
|
||||
]
|
||||
sc = _combo(world["system"], lines, "섞음")
|
||||
saved = _read(world["system"], "precombo", sc)["담은로직"]
|
||||
assert saved[1:] == [
|
||||
{"로직": STONE, "입력값": {}, "수량": {"손": 3}, "메모": "돌"},
|
||||
{"로직": TEN, "입력값": {}, "수량": {"손": 1}, "메모": None},
|
||||
assert saved[1:] == [ # 723-1 — 안 줄 수량 저장 안 함
|
||||
{"로직": STONE, "입력값": {}, "수량": None, "메모": "돌"},
|
||||
{"로직": TEN, "입력값": {}, "수량": None, "메모": None},
|
||||
]
|
||||
ten, stone = (Decimal(str(_base(c)["계"])) for c in (TEN, STONE))
|
||||
want = ten / 10 * 2 + stone * 3 + ten / 10 * 1 # 손셈 — 승격 · TEN 은 원/10㎡(배 10)
|
||||
want = ten / 10 * 2 + stone * 3 + ten / 10 * 1 # 이은 수량 — 승격 · TEN 은 원/10㎡(배 10)
|
||||
ref = {**SYS, "키": sc}
|
||||
given = {f"{refs.ref_id(ref)}#{i}": q for i, q in enumerate((2, 3, 1))}
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]})
|
||||
one = labor.calc(doc, {})["줄"][0]
|
||||
one = labor.calc(doc, given)["줄"][0]
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN]
|
||||
@@ -482,16 +495,16 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
|
||||
bundle = prices.to_json(prices.collect([TEN, STONE]))
|
||||
for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값
|
||||
(world["system"] / refs.kind_of(key) / f"{key}.json").unlink()
|
||||
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
|
||||
got = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0]
|
||||
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
|
||||
|
||||
|
||||
def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
|
||||
def test_사전조합이_자기_자신을_담으면_422_수량_없음(world):
|
||||
sp = _promote(world["system"])
|
||||
a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가")
|
||||
with store.at(world["system"]):
|
||||
have = store.read("precombo", a)
|
||||
assert have["문서"]["담은로직"][0]["수량"] == {"손": 1}
|
||||
assert have["문서"]["담은로직"][0]["수량"] is None # 723-1 — 기본 1 걷음
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.write(
|
||||
"precombo",
|
||||
@@ -535,14 +548,16 @@ def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, mon
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]})
|
||||
rows = source.labor_doc_rows(doc)
|
||||
given = {f"{refs.ref_id(ref)}#0": 1}
|
||||
kept = rows["참조사본"][refs.ref_id(ref)]["알약"]
|
||||
assert [(p["로직"], p.get("배")) for p in kept] == [([TEN], 10)]
|
||||
assert "알약" not in _read(world["system"], "precombo", sc) # 층 문서는 그대로
|
||||
bundle = prices.to_json(prices.collect([TEN]))
|
||||
before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"]
|
||||
before = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0]["기준"]["계"]
|
||||
changed = {"물량": [{"키": "x|㎡", "이름": "x", "단위": "㎡", "로직": [TEN]}]} # 배 1 로 바뀜
|
||||
monkeypatch.setattr(combo_store, "combo_pills", lambda row: changed)
|
||||
assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] == pytest.approx(before)
|
||||
again = prices.calc(rows, bundle, {}, {}, _area(given))["줄"][0]["기준"]["계"]
|
||||
assert again == pytest.approx(before)
|
||||
with store.at(world["system"]):
|
||||
live = labor.calc(doc, {})["줄"][0]["기준"]["계"]
|
||||
live = labor.calc(doc, given)["줄"][0]["기준"]["계"]
|
||||
assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1)
|
||||
|
||||
@@ -181,7 +181,10 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page
|
||||
assert "const company = viewCompany(entry, user);" in page
|
||||
assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0]
|
||||
# 720-4 — 다른 층 = 저장 안 한 고침 물음 뒤 제자리 바꿈(해시 이동 · 페이지 다시 세움 없음)
|
||||
go = page.split("const go =")[1].split("};")[0]
|
||||
assert "await main.confirmLeave()" in go and "await switchStructure(next, nextOwner);" in go
|
||||
assert "navigateTo" not in page
|
||||
assert 'dispose: () => setActiveLayer("system")' in page
|
||||
assert "side.reveal(entry.open)" in page
|
||||
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
@@ -192,9 +195,9 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
|
||||
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
|
||||
assert 'L("M02_PeopleEmpty")' in roots
|
||||
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
|
||||
assert 'const many = want === "personal" && seesOthers(company, user);' in page
|
||||
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
|
||||
assert 'peopleTree("구조물조합", tree.root)' in page
|
||||
assert "peopleTree(tree.root)" in page
|
||||
assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
@@ -223,8 +226,8 @@ def test_층_길_접두어와_고정_풀림():
|
||||
in labor
|
||||
)
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert "const layer = opt.layer;" in side
|
||||
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
|
||||
assert 'const layer: Layer = "system";' in side # 도면 양식은 시스템 층에만
|
||||
assert "const drawing = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert 'sel.layer !== "system"' not in main
|
||||
assert 'return layer === "project" || isAdmin;' in main
|
||||
@@ -236,7 +239,7 @@ def test_읽기만_회사용_개인용으로_복사_막대():
|
||||
assert (
|
||||
"readOnlyBar?.setColumn(column)" in page
|
||||
) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음
|
||||
assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로
|
||||
assert "if (accessFor(lay, who, user).needCompany)" in page # 회사 없음 = 마스터로
|
||||
assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
assert 'title: L("M02_Title")' in page # 한 화면 한 제목
|
||||
layer = _text(LAYER)
|
||||
@@ -290,15 +293,16 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
|
||||
assert "layerBar(tree.add, () => void main.openTable(layer))" in page
|
||||
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
|
||||
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
|
||||
assert "(to === layer && toOwner === here)) return [];" in page
|
||||
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
|
||||
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, clip.refs.length);" in copy_to
|
||||
assert "void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);" in _text(
|
||||
M02 / "M02_MasterTemplete_TreePanel.ts"
|
||||
)
|
||||
) # 720-2 — 몸은 CopyTo 로 뗌(TreePanel 700 줄)
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
@@ -315,12 +319,10 @@ def test_구조물_조합_제목_줄_새_항목():
|
||||
r"\s*\.\.\.\(extras\.newItem \? \[extras\.newItem\] : \[\]\),?\s*\]",
|
||||
main,
|
||||
)
|
||||
assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main
|
||||
# 720-4 — 쓸 수 있는 층만 = 편집 칸이 고른 것 권한으로 가림(층이 제자리에서 바뀜)
|
||||
assert "extras.newItem.hidden = !combo || !sel || !writable(sel);" in main
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert (
|
||||
"...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {})"
|
||||
in page
|
||||
)
|
||||
assert "newItem: newItemButton(() => void openNew())," in page
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder())
|
||||
assert "const newItem = (): void => void addItem(folder());" in panel
|
||||
|
||||
@@ -51,9 +51,9 @@ def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_co
|
||||
|
||||
def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만():
|
||||
side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert 'layer === "system" && opt.isAdmin' in side
|
||||
assert "const practice = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘
|
||||
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
|
||||
assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래
|
||||
assert order.index("drawing ?") < order.index("practice ?") # 도면 양식 아래
|
||||
view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8")
|
||||
assert view.count("showConfirmDialog(") == 2
|
||||
assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음
|
||||
|
||||
@@ -72,11 +72,17 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
|
||||
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
|
||||
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
|
||||
bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar
|
||||
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
|
||||
assert page.count('layerBar("') == 1
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split(
|
||||
"const side = buildSide("
|
||||
)[0]
|
||||
# 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표
|
||||
go = bar.split("go: (next) =>")[1].split("reselect:")[0]
|
||||
assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0]
|
||||
assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
# 구조물 조합 층 단추 한 벌(720-4 제자리 바꿈)
|
||||
assert page.count("layerBar(") == 1
|
||||
assert "layerBar(tree.add, () => void main.openTable(layer))," in page
|
||||
assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음
|
||||
|
||||
|
||||
@@ -88,7 +94,7 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
assert "listTemplates(at.layer, null, at.owner)" in lib_side
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert (
|
||||
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);"
|
||||
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin());"
|
||||
in main
|
||||
)
|
||||
assert "readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner)" in main
|
||||
@@ -148,7 +154,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7
|
||||
"summary,",
|
||||
"createDivider()",
|
||||
"hopyo.title",
|
||||
"drawing ?",
|
||||
"drawing,",
|
||||
"practice ?",
|
||||
]
|
||||
at = [order.index(m) for m in marks]
|
||||
|
||||
@@ -180,7 +180,7 @@ def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이():
|
||||
def test_첫_화면과_닫은_뒤는_구조물집계표():
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert re.search(r'kind: "table",\s*name: STRUCTURE_TABLE', page)
|
||||
assert "main.open(sel ?? home)" in page and "void main.open(home);" in page
|
||||
assert "main.open(sel ?? home())" in page and "void main.open(home());" in page
|
||||
|
||||
|
||||
def test_좌측_고름_표시는_열린_것_한_줄뿐():
|
||||
|
||||
Reference in New Issue
Block a user