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

This commit is contained in:
2026-10-31 15:25:01 +09:00
35 changed files with 844 additions and 57 deletions
@@ -154,7 +154,7 @@ def test_사본_없는_시설_알림_응답에_사본(root):
out = _run(root)
# 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳
assert not any("wing" in c["id"] for c in out["columns"])
assert "조합 사본 없는 시설 7곳 — 노랑 램프 줄을 눌러 조합 연결" in out["notes"]
assert "구조물 조합 없는 시설 7곳 — 노랑 ● 줄을 눌러 구조물 조합 고르기" in out["notes"]
assert not any("관 연장 없음" in n for n in out["notes"])
assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"}
@@ -63,7 +63,7 @@ def test_157_2_두_보기_같은_값():
def test_157_2_단추_바꿈():
out = _node("\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')]));")
assert out == ["호표보기", "박스보기"] # 304-1 단추 글 = 그 보기 이름
assert out == ["표 보기", "카드 보기"] # 304-1 단추 글 = 그 보기 이름
def test_157_2_기억_실패_때_카드():
+3 -3
View File
@@ -298,7 +298,7 @@ table.onCell(at("earth:4"), "connect");
table.onCell(at("hand:c"), "connect"); // 연결 없음 = 단가연결 창 바로(메뉴 없음)
await new Promise((r) => setTimeout(r, 0));
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
picked.rows.find((r) => r.tag === "대가3").onPick();
picked.rows.find((r) => r.tag === "일위대가 3호").onPick();
out.picked = [picked.title, picked.kinds, picked.rows.map((r) => r.tag)];
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
console.log(JSON.stringify(out));
@@ -329,8 +329,8 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
# 연결 없는 줄 = 단가연결 창 바로(종류 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘)
unlink = [10, 40, ["단가연결", "-", "단가연결 풀기"]]
assert out["menus"] == [unlink, unlink]
kinds = ["단가산출", "일위대가 사전조합", "구조물 조합", "일위대가 호표", "단가산출 호표"]
assert out["picked"] == ["단가연결", kinds, ["대가3"]]
kinds = ["단가산출", "일위대가(자료집)", "구조물 조합", "이 공사 일위대가", "이 공사 단가산출"]
assert out["picked"] == ["단가연결", kinds, ["일위대가 3호"]]
assert out["ref"] == DG
@@ -202,9 +202,9 @@ def test_셸_배선():
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert (
"panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))"
in src
) # 203 레벨 안내 + 탭 안내 = 한 영역
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src
and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src
) # 203 레벨 안내 + 탭 안내 = 한 영역 · 703-1 창고 있으면 표 칸 + 오른쪽 서랍
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
assert "b09-estimation__level" not in lvl
@@ -106,7 +106,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in shell
and "panel.replaceChildren(...banner, ...(row ? [row] : body));"
in shell # 701-3 띠 = 표 위
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
@@ -24,7 +24,7 @@ SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
def test_묶음_이름_단추_글_설명_없음():
assert (
'createPanelCard("데이터 점검", true)' in SHELL and "원가 단가" not in SHELL
'createPanelCard("내역 점검", true)' in SHELL and "원가 단가" not in SHELL
) # 269 공용 카드
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
@@ -95,7 +95,7 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector
a, b = out["a"], out["b"]
assert a["pending"] and not b["pending"]
# 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로
assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳"
assert a["texts"][0] == "금액 빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳"
assert a["rows"] == [
["0", "ui-sidebar-row", "제근설계내역서"],
["1", "ui-sidebar-row", "옹벽설계내역서"],
@@ -109,7 +109,7 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
assert b["texts"] == ["빈 줄 0"] and b["rows"] == []
assert b["texts"] == ["금액 빈 줄 0"] and b["rows"] == []
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
@@ -231,7 +231,7 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
pre, (url, body) = out["sent"][0], out["sent"][1]
kinds = ["logic", "precombo", "combo", "dg", "sg"]
assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기
assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]]
assert out["combo"] == [["구조물 조합", "관매설", "마스터"]] # 703-4 번호 숨김
assert url == "/api/projects/P1/estimation/library"
assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"}
assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None,
@@ -87,7 +87,7 @@ def test_좌측_스위치_누름():
"\nconsole.log(JSON.stringify({ name: row.dataset.b09Switch, labels, a, b, c: [redraws, pressed()],"
" view: groupView(), kept: Object.fromEntries(kept) }));",
)
assert out["name"] == "view" and out["labels"] == ["박스보기", "호표보기"]
assert out["name"] == "view" and out["labels"] == ["카드 보기", "표 보기"]
assert out["a"] == ["true", "false"] # 기본 = 박스보기
assert out["b"] == [0, ["true", "false"]]
assert out["c"] == [1, ["false", "true"]] and out["view"] == "table"
+2 -2
View File
@@ -100,8 +100,8 @@ def test_B07_첫_장은_표지_기억_있으면_그_장():
def test_B09_기본_접힘_좌측은_한_번만_세움():
shell = _read("B09_Estimation/B09_Estimation_UI_Shell.ts")
assert 'createPanelCard("데이터 점검", true)' in shell
assert 'createPanelCard("공기 옵션", true)' in _read(
assert 'createPanelCard("내역 점검", true)' in shell
assert 'createPanelCard("공사기간 조건", true)' in _read(
"B09_Estimation/draft/B09_Draft_Schedule_Panel.ts"
)
# 다시 그림(show) 은 가운데 판만 바꿈 — 좌측 카드는 쪽 그릴 때 한 번
@@ -92,5 +92,5 @@ def test_띠_글_빈_줄_0_이면_없음():
) # fmt: skip
assert result.returncode == 0, result.stderr
banner, none = json.loads(result.stdout)
assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner
assert banner.startswith("금액 빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner
assert none is None
+147
View File
@@ -0,0 +1,147 @@
"""703-1 B09 재료 창고 「지난 공사」 — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본(덮개째).
- 목록 = 그 공사 서고 사본 · 구조물 사본 · 고친 칸 수(그 공사 저장 덮개) · 같은 것 한 줄
- 가져옴 = 사본 폴더 통째 → `library/…~해시` · 가져온곳 · 덮개 = 그 공사 칸 · 다시 = 같은 사본
- 새 호표(`libs`) · 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록 cover
- 모양 틀림 400 · 없음 404 · 다른 회사 403 · 이 공사 400
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
"""
import asyncio
import json
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation import B09_Estimation_Past as past # 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 config import config_system # noqa: E402
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402
STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡
@pytest.fixture()
def world(tmp_path, monkeypatch):
system = copy_master_sample(tmp_path / "master")
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
key = refs.create(
"precombo",
{"이름": "지난 조합", "단위": "㎡", "담은로직": [{"로직": STONE, "수량": {"손": 2}}]},
)["이름"]
a, b = (tmp_path / "storage" / "7" / "1" / n for n in ("past", "here"))
a.mkdir(parents=True)
b.mkdir(parents=True)
folder = library.import_copy(a, "precombo", "system", None, key)["폴더"]
ref = {"층": "system", "주인": None, "키": key}
cover = {
f"C:{refs.ref_id(ref)}": {"sangeun:GF#0": {"qty": 5}},
"K:x": {"@copy": {"from": "S:y"}},
}
store_b09.write_user(a, {"daega_cover": cover})
return {"key": key, "a": a, "b": b, "folder": folder}
def test_목록_서고_사본_고친_칸_수_같은_것_한_줄(world):
a = world["a"]
rows = past.rows_of(a)
assert [(r["cover"], r["name"], r["unit"], r["kind"], r["covers"]) for r in rows] == [
(f"L:{world['folder']}", "지난 조합", "㎡", "사전조합", 1)
]
# 같은 원본 · 같은 값 · 덮개 없음 = 한 줄로 셈(값 해시 다른 사본)
library.import_copy(a, "precombo", "system", None, world["key"], haul={"given": {}})
assert len(past.rows_of(a)) == 1
def test_가져옴_사본_통째_덮개째_새_호표_다시는_같은_사본(world):
a, b, folder = world["a"], world["b"], world["folder"]
ref = {"project_id": "P-A", "프로젝트": "지난"}
made = past.import_past(b, a, ref, f"L:{folder}", {"이름": "나"})
name = made["폴더"]
assert made["만듦"] and made["덮개"] == {"sangeun:GF#0": {"qty": 5}}
assert name.startswith(f"precombo@system@-@{world['key']}~") and library.NAME.match(name)
copy = library.library_dir(b) / name
src = library.library_dir(a) / folder
for f in ("일위대가.json", "단가.json"):
assert (copy / f).read_bytes() == (src / f).read_bytes()
manifest = json.loads((copy / "manifest.json").read_text("utf-8"))
assert manifest["가져온곳"] == {**ref, "사본": f"L:{folder}"} and manifest["종류"] == "precombo"
got = daega_inputs(b)
bid = got["libs"][name]
block = next(x for x in got["blocks"] if x["id"] == bid)
# 사전조합 덮개 = 참조(공사 안 같은 사전조합 한 덮개) — 덮개자리 = 그 cover
assert (block["name"], block["cover"]) == ("지난 조합", made["덮개자리"])
assert made["덮개자리"] == "C:참조|system||" + world["key"]
again = past.import_past(b, a, ref, f"L:{folder}")
assert (again["폴더"], again["만듦"]) == (name, False)
def test_같은_꼴_블록이_먼저_있으면_그_cover(world):
a, b = world["a"], world["b"]
mine = library.import_copy(b, "precombo", "system", None, world["key"])["폴더"]
name = past.import_past(b, a, {"project_id": "P-A"}, f"L:{world['folder']}")["폴더"]
got = daega_inputs(b)
assert got["libs"][name] == got["libs"][mine]
block = next(x for x in got["blocks"] if x["id"] == got["libs"][name])
assert block["cover"].startswith("C:") # 사전조합 = 같은 참조 한 덮개(화면이 빈 때만 얹음)
def test_구조물조합_사본_덮개자리_새_폴더(world):
a, b = world["a"], world["b"]
folder = library.import_copy(a, "combo", "system", None, "SS000011")["폴더"]
store_b09.write_user(a, {"daega_cover": {f"L:{folder}": {"x#0": {"qty": 2}}}})
row = next(r for r in past.rows_of(a) if r["cover"] == f"L:{folder}")
assert (row["kind"], row["covers"]) == ("구조물 조합", 1)
made = past.import_past(b, a, {"project_id": "P-A"}, f"L:{folder}")
assert made["덮개자리"] == f"L:{made['폴더']}" and made["덮개"] == {"x#0": {"qty": 2}}
got = daega_inputs(b)
block = next(x for x in got["blocks"] if x["id"] == got["libs"][made["폴더"]])
assert block["cover"] == made["덮개자리"]
def test_모양_틀림_없음(world):
a, b = world["a"], world["b"]
for bad in ("L:../x", "X:y", "S:../../z"):
with pytest.raises(ValueError):
past.import_past(b, a, {}, bad)
with pytest.raises(LookupError):
past.import_past(b, a, {}, "S:nope")
with pytest.raises(LookupError):
past.import_past(b, a, {}, "L:precombo@system@-@SC-000999")
def test_길_다른_회사_403_이_공사_400(world, monkeypatch):
rows = {
"here": {"company_id": 7, "root": world["b"]},
"far": {"company_id": 8, "root": world["a"]},
}
async def project(_session, pid):
return {"name": pid, **rows["here" if pid.startswith("0") else "far"]}
monkeypatch.setattr(router, "_project", project)
pid = router.UUID("00000000-0000-0000-0000-000000000001")
body = router.PastBody(project_id="far", cover=f"L:{world['folder']}")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, body, {"user_id": 1}))
assert e.value.status_code == 403
same = router.PastBody(project_id=str(pid), cover="L:x")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, same, {"user_id": 1}))
assert e.value.status_code == 400
assert not library.library_dir(world["b"]).exists() # 아무것도 안 씀