Merge remote-tracking branch 'origin/dev' into sub_laptop_8

This commit is contained in:
2026-11-01 21:55:20 +09:00
52 changed files with 2501 additions and 747 deletions
+155
View File
@@ -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,163 @@
"""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
+10 -9
View File
@@ -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):
@@ -0,0 +1,96 @@
"""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(add)" 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 = opt.isSystem" in side and "const practice = opt.isSystem" in side
hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo
+106
View File
@@ -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["줄"]] == [["노무비"], ["재료비"]]
+113
View File
@@ -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"}]
+1 -1
View File
@@ -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 # 상세 제목
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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 # 결과 층 = 쓸 수 있는 층만
+16 -14
View File
@@ -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
+2 -2
View File
@@ -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 # 고칠 칸 없음
+9 -7
View File
@@ -72,15 +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]
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 "main.confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
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 "main.confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
assert page.count('layerBar("') == 1
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
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 덧붙음
@@ -92,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
+1 -1
View File
@@ -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_좌측_고름_표시는_열린_것_한_줄뿐():