Files
Aislo/resources/tester/test_item_copy_complete.py
T

343 lines
15 KiB
Python

"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본).
- 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판
- 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값
- 마스터 단가 한 줄을 바꿔도 프로젝트 계산 그대로 · 목록에 「마스터 바뀜」 · 새로 가져오기 뒤 바뀜
(입력값 · 고른자재는 같은 키면 살림)
- 쓰기 뒤 다시 읽기 같음 · 판 다르면 409 · 모양 틀리면 400 · 고친 복사본은 안 지움
- 옛 복사본은 읽을 때 채움 · 미리 가져오기 · 남의 프로젝트 403
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes
from M02_MasterTemplete import M02_Project_Items_Router as items_router
from M02_MasterTemplete import M02_Template_Layers as layers
REAL = config_system.PROJECT_ROOT / "resources/master_template"
MASTER = config_system.PROJECT_ROOT / "resources/master_data"
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
FOLDER = "system_none_SS000011"
LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀
@pytest.fixture(scope="module")
def master_copy(tmp_path_factory) -> Path:
target = tmp_path_factory.mktemp("master_data")
for path in MASTER.glob("*.json"):
shutil.copy2(path, target / path.name)
return target
@pytest.fixture
def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
for name in (layers.COMBO_FILE, "자재조합.json"):
shutil.copy2(REAL / name, system / name)
for kind in ("table", "structure", "area", "material"):
shutil.copytree(REAL / kind, system / kind)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(m01, "FOLDER", master_copy)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
return {"system": system, "root": root, "master": master_copy}
def _save(root: Path, pipes: list[dict]) -> dict[str, list[str]]:
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": []}), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8"
)
return snap.sync_snapshots(root)
def _pipe(summary: dict | None = PIPE) -> dict[str, Any]:
return {"chainage_m": 40.0, "source": "user", "summary_item": summary}
def _read(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _auto_values(root: Path) -> dict[str, Any]:
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
labor = _read(root / "templates/items" / FOLDER / "일위대가.json")
_, whole = m01.loaded()
out: dict[str, Any] = {}
for row in labor["로직"]:
values = m01.mau.values(whole, whole.logic(row["키"]))
out[row["키"]] = {k: {"값": prices.to_json(v), "확인": True} for k, v in values.items()}
return out
def _fill_values(root: Path) -> dict[str, Any]:
current = copies.read_file(root, FOLDER, "입력값")
values = _auto_values(root)
copies.write_file(root, FOLDER, "입력값", current["판"], values)
return values
# ── 새 파일 ──────────────────────────────────────────
def test_지정하면_새_파일까지_복사(world):
root = world["root"]
assert _save(root, [_pipe()])["만듦"] == [FOLDER]
folder = root / "templates/items" / FOLDER
assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음
assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {}
price = _read(folder / "단가.json")
rows = {k: len(v.get("줄") or v.get("표")) for k, v in price.items()}
assert rows["인력.json"] >= 2 and rows["재료_자재품목.json"] > 0 and "_빠진" not in price
keys = {row["키"] for data in price.values() for row in data.get("줄") or data.get("표")}
assert {"GF000176", "GC000088", "GF000113", "GC000267", LABOR_PRICE} <= keys
manifest = _read(folder / "manifest.json")
assert manifest["가져온날"] and len(manifest["원본판"]) == 16
assert copies.list_items(root) == [
{
"폴더": FOLDER,
"이름": "관공 관매설",
"원본": PIPE,
"원본판": manifest["원본판"],
"가져온날": manifest["가져온날"],
"마스터바뀜": False,
"원본없음": False,
"구조물": ["pipe@40.0"],
"고침": False,
}
]
def test_층_입력연결을_복사(world):
root, system = world["root"], world["system"]
(system / "inputs").mkdir()
link = {"판": "a1", "줄": [{"로직": "GF000176", "입력": "관경", "자리": "면적", "알약": "x"}]}
column = next(
r["열"] for r in _read(system / layers.COMBO_FILE)["줄"] if r["키"] == PIPE["key"]
)
(system / "inputs" / f"{column}.json").write_text(json.dumps(link, ensure_ascii=False), "utf-8")
_save(root, [_pipe()])
assert copies.read_file(root, FOLDER, "입력연결")["내용"] == link
def test_단가_묶음은_닫혀_있음(world):
"""조합마다 복사본 단가 묶음만으로 돌린 값 = 마스터로 돌린 값(자동값 · 고르기 끝값)."""
files, whole = prices.master()
combos = [r for d in files.values() if d.get("그룹") == "일위대가조합" for r in d["줄"]]
runs = 0
for ua in combos:
keys = prices.mcb.logic_keys(ua)
snapshot = prices.load_prices(prices.to_json(prices.collect(keys)))
for key in keys:
try:
row = whole.logic(key)
except prices.mf.FormulaError:
continue
for pick in (0, -1):
values = m01.mau.values(whole, row)
for spec in row.get("입력", []):
if spec.get("고르기"):
values[spec["이름"]] = spec["고르기"][pick]
assert _run(whole, key, values) == _run(snapshot, key, values), (ua["키"], key)
runs += 1
assert len(combos) >= 30 and runs >= 300
def _run(master: Any, key: str, values: dict) -> str:
try:
got = prices.mf.run(master, key, dict(values))
except Exception as error: # 까닭도 같아야 함
return f"멈춤 {error}"
return str(got.get("계", got.get("결과")))
# ── 계산 · 마스터 바뀜 · 새로 가져오기 ───────────────────
def test_입력이_없으면_빠진_입력_목록(world):
root = world["root"]
_save(root, [_pipe()])
got = copies.calc(root, FOLDER)
assert got["계"] == 0 and len(got["멈춤"]) == 3
assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"]
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
root, master_dir = world["root"], world["master"]
_save(root, [_pipe()])
values = _fill_values(root)
before = copies.calc(root, FOLDER)
assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0
files, whole = m01.loaded()
ua = next(
r
for d in files.values()
if d.get("그룹") == "일위대가조합"
for r in d["줄"]
if r["키"] == "UA000036"
)
given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()}
expected = prices.to_json(prices.mcb.run(whole, ua, given))
assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0
labor_file = master_dir / "인력.json"
original = labor_file.read_bytes()
try:
data = _read(labor_file)
row = next(r for r in data["줄"] if r["키"] == LABOR_PRICE)
row["값"] = row["값"] * 2
labor_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
assert copies.calc(root, FOLDER) == before # 복사본만 씀
assert copies.list_items(root)[0]["마스터바뀜"] is True
copies.refresh(root, FOLDER)
after = copies.calc(root, FOLDER)
assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"]
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림
assert copies.list_items(root)[0]["마스터바뀜"] is False
finally:
labor_file.write_bytes(original)
def test_새로_가져오면_없는_키는_버림(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/items" / FOLDER
(folder / "입력값.json").write_text(
json.dumps(
{
"GF000176": {"관경": {"값": 600, "확인": True}, "옛입력": {"값": 1}},
"GX999999": {"a": {"값": 1}},
},
ensure_ascii=False,
),
"utf-8",
)
(folder / "고른자재.json").write_text(
json.dumps({"GF000176": {"0": "MT000001", "99": "MT000002"}}), "utf-8"
)
copies.refresh(root, FOLDER)
assert _read(folder / "입력값.json") == {"GF000176": {"관경": {"값": 600, "확인": True}}}
assert _read(folder / "고른자재.json") == {"GF000176": {"0": "MT000001"}}
# ── 쓰기 · 판 · 모양 · 지우기 규칙 ─────────────────────────
def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world):
root = world["root"]
_save(root, [_pipe()])
first = copies.read_file(root, FOLDER, "고른자재")
wrote = copies.write_file(
root, FOLDER, "고른자재", first["판"], {"GF000176": {"0": "MT000010"}}
)
again = copies.read_file(root, FOLDER, "고른자재")
assert again["내용"] == {"GF000176": {"0": "MT000010"}} and again["판"] == wrote["판"]
with pytest.raises(copies.Stale):
copies.write_file(root, FOLDER, "고른자재", first["판"], {})
for kind, bad in (
("입력값", {"GF000176": {"관경": 600}}),
("입력값", {"GF000176": {"관경": {"값": 1, "확인": "네"}}}),
("고른자재", {"GF000176": {"첫": "MT000001"}}),
("입력연결", {"줄": [{"로직": "GF000176", "입력": "관경", "자리": "어딘가"}]}),
("도면", {}),
):
with pytest.raises(ValueError):
copies.write_file(root, FOLDER, kind, again["판"], bad)
with pytest.raises(LookupError):
copies.read_file(root, "../../etc", "입력값")
def test_고친_복사본은_구조물에서_빠져도_안_지움(world):
root = world["root"]
_save(root, [_pipe()])
assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙)
_save(root, [_pipe()])
current = copies.read_file(root, FOLDER, "입력값")
copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}})
assert _save(root, [_pipe(None)])["지움"] == []
assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"]
def test_미리_가져오기(world):
root = world["root"]
_save(root, [])
made = copies.prefetch(root, PIPE)
assert made["폴더"] == FOLDER and made["만듦"] is True
assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로
assert copies.list_items(root)[0]["구조물"] == []
assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움
def test_옛_복사본은_읽을_때_채움(world):
root = world["root"]
_save(root, [_pipe()])
folder = root / "templates/items" / FOLDER
for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"):
(folder / name).unlink()
manifest = _read(folder / "manifest.json")
for key in ("원본판", "가져온날"):
manifest.pop(key)
(folder / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8")
assert copies.read_file(root, FOLDER, "입력값")["내용"] == {}
filled = _read(folder / "manifest.json")
assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"]
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"]
assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본
# ── 길 · 권한 ────────────────────────────────────────
def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
root = world["root"]
_save(root, [_pipe()])
async def fake_row(project_id: str) -> dict[str, Any]:
return {"company_id": 3, "storage_path": str(root)}
monkeypatch.setattr(layer_routes, "_project_row", fake_row)
monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path)
app = FastAPI()
app.include_router(items_router.router)
session = {"role": "USER", "company_id": 3}
app.dependency_overrides[verify_session] = lambda: session
client = TestClient(app)
base = "/api/projects/p1/items"
assert client.get(base).json()["항목"][0]["폴더"] == FOLDER
got = client.get(f"{base}/{FOLDER}/입력값").json()
put = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"], "내용": {}})
assert put.status_code == 200
stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}})
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400
made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json()
assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True
names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]}
assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름
assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404
session["company_id"] = 4
assert client.get(base).status_code == 403
assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403