Files
Aislo/resources/tester/test_m02_labor_ref.py
T
eomsangdonandClaude Opus 5.5 9d9955631f feat(M02): 일위대가 조합 서고 — 좌측 항목 둘 · 상세 목록표(51-4)
- 좌측 컨테이너 = 층 단추 + 「단가산출 승격」 · 「일위대가 사전조합」 항목만 · 새로 · 본떠 · 지우기 단추 없앰
- 항목 누름 = 상세 칸 목록표(고름 · 키 · 이름 · 단위 · 기준 금액 · 고친 날) · 줄 누름 = 그 문서 상세
- 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 까닭 알림)
- 층 목록 서고 줄에 1 단위 기준 계(`기준금액`) 실음 · 시험 한 개 · 화면 시나리오 51-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:39:07 +09:00

341 lines
15 KiB
Python

"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄.
펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 ·
프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage).
"""
import json
from decimal import Decimal
import pytest
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 M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_calc_mirror import _same, _ts
from resources.tester.test_m02_structure import write_sample_table
TEN = "GC000421" # 원/10㎡ — 배 10
STONE = "GF000219" # 원/㎡
PLAIN = "GC000422" # 돈 아닌 로직
SYS = {"층": "system", "주인": None}
COMPANY = {"층": "company", "주인": "7"}
MINE = {"층": "personal", "주인": "7/42"}
@pytest.fixture()
def world(tmp_path, monkeypatch):
from config import config_system
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
store.create_structure("rv")
return {
"system": system,
"company": layers.company_dir(7),
"personal": layers.personal_dir(7, 42),
}
def _promote(folder, code=TEN, **extra):
with store.at(folder):
return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"]
def _combo(folder, lines, name="조합"):
with store.at(folder):
return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"]
def _read(folder, kind, key):
return json.loads((folder / kind / f"{key}.json").read_bytes())
def _base(code):
import M01_MasterData.M01_MasterData_Store as m01
return labor.run_line(m01.loaded()[1], code, {})["기준"]
# ── 자료 틀 ────────────────────────────────────────────────────────────
def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world):
assert _promote(world["system"]) == "SP-000001"
assert _promote(world["system"], STONE, 이름="돌") == "SP-000002"
assert _promote(world["personal"]) == "SP-000001" # 층마다 셈
doc = _read(world["system"], "promote", "SP-000001")
assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10)
assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}]
assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌"
@pytest.mark.parametrize(
"body",
[
{"담은로직": []},
{"담은로직": [{"로직": TEN}, {"로직": STONE}]},
{"담은로직": [{"로직": "GF999999"}]},
{"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]},
],
)
def test_틀린_승격은_400(world, body):
_promote(world["system"])
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
refs.create("promote", body)
assert e.value.status == 400
def test_사전조합_틀린_줄_400_순환_422(world):
sp = _promote(world["system"])
one = {"참조": {**SYS, "키": sp}}
for lines in (
[{"로직": TEN}], # 로직은 못 담음(승격 · 사전조합만)
[one, one], # 같은 참조 두 번
[{"참조": {**SYS, "키": "SP-000009"}}], # 없는 참조
[{"참조": {**SYS, "키": "SU-000001"}}], # 서고 키 아님
):
with pytest.raises(store.StoreError) as e:
_combo(world["system"], lines)
assert e.value.status == 400
a = _combo(world["system"], [one], "가")
b = _combo(world["system"], [{"참조": {**SYS, "키": a}}], "나")
with store.at(world["system"]):
have = store.read("precombo", a)
with pytest.raises(store.StoreError) as e:
store.write(
"precombo",
a,
have["판"],
{**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]},
)
assert e.value.status == 422 and f"{a} → {b} → {a}" in e.value.detail
def test_층_규칙은_위_층만(world):
_promote(world["system"])
_promote(world["company"])
_promote(world["personal"])
ok = [{"참조": {**s, "키": "SP-000001"}} for s in (SYS, COMPANY, MINE)]
assert _combo(world["personal"], ok) == "SC-000001" # 개인 → 개인 · 회사 · 마스터
for folder, bad in (
(world["company"], MINE), # 회사 → 개인 X
(world["system"], COMPANY), # 마스터 → 회사 X
(world["personal"], {"층": "personal", "주인": "7/43"}), # 남의 개인 X
(world["personal"], {"층": "company", "주인": "8"}), # 남의 회사 X
):
with pytest.raises(store.StoreError) as e:
_combo(folder, [{"참조": {**bad, "키": "SP-000001"}}])
assert e.value.status == 400 and "위 층만" in e.value.detail
def test_일위대가_참조_줄_정리_같은_참조_두_번_400(world):
sp = _promote(world["system"])
line = {"참조": {**SYS, "키": sp}, "별칭": " 승격 ", "수량": {"손": 3}}
with store.at(world["system"]):
got = labor.clean({"열": "rv", "담은로직": [line]})["담은로직"]
assert got == [{"참조": {**SYS, "키": sp}, "별칭": "승격", "메모": None, "수량": {"손": 3}}]
with pytest.raises(store.StoreError) as e:
labor.clean({"열": "rv", "담은로직": [line, line]})
assert e.value.status == 400
# ── 펼침(계산) ─────────────────────────────────────────────────────────
def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합은_수량_곱(world):
sp = _promote(world["system"])
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}], "둘")
ref = {**SYS, "키": sc}
with store.at(world["system"]):
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]})
got = labor.calc(doc, {})
one = got["줄"][0]
want = Decimal(str(_base(TEN)["계"])) / 10 * 2
assert one["기준"]["계"] == pytest.approx(float(want))
assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref))
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}]
def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world):
sp = _promote(world["system"])
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "하나")
with store.at(world["system"]):
doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": k}} for k in (sp, sc)]})
promote, combo = labor.calc(doc, {})["줄"]
assert promote["원로직"] == TEN and promote["줄"]
assert sum(x["금액"] for x in promote["줄"]) == pytest.approx(float(_base(TEN)["계"]))
assert "줄" not in combo and [x["참조"]["키"] for x in combo["안"]] == [sp]
def test_펼침은_순환_깊이_없는_참조를_까닭으로_멈춤(world):
folder = world["system"] / "precombo"
for key, child in (("SC-000001", "SC-000002"), ("SC-000002", "SC-000001")):
body = {"이름": key, "담은로직": [{"참조": {**SYS, "키": child}}]}
(folder / f"{key}.json").write_text(json.dumps(body), encoding="utf-8")
import M01_MasterData.M01_MasterData_Store as m01
expand = refs.Expand(m01.loaded()[1])
got = expand({**SYS, "키": "SC-000001"})
assert got["기준"] is None and "순환" in got["까닭"]
assert "없는 참조" in expand({**SYS, "키": "SC-000009"})["까닭"]
deep = refs.Expand(
m01.loaded()[1], lambda r: {"담은로직": [{"참조": {**SYS, "키": "SC-000001"}}]}
)
assert deep.__call__({**SYS, "키": "SC-000001"}, ())["기준"] is None
def test_돈_아닌_승격은_까닭(world):
sp = _promote(world["system"], PLAIN)
import M01_MasterData.M01_MasterData_Store as m01
got = refs.Expand(m01.loaded()[1])({**SYS, "키": sp})
assert got["기준"] is None and got["까닭"]
def test_참조_줄_settle_파이썬과_TS_가_같은_답(world):
sp = _promote(world["system"])
ref = {**SYS, "키": sp}
with store.at(world["system"]):
doc = labor.clean(
{"열": "rv", "담은로직": [{"로직": STONE, "수량": None}, {"참조": ref, "수량": None}]}
)
answer = labor.calc(doc, {})
values = {refs.ref_id(ref): 4}
py = labor.settle(answer, doc, values)
assert py["줄"][1]["수량출처"] == "칸"
_same(py, _ts(answer, doc, values))
# ── 지우기 · 복사 · 굳힘 · 로직 지우기 ─────────────────────────────────
def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world):
sp = _promote(world["system"])
with store.at(world["personal"]):
line = {"참조": {**SYS, "키": sp}}
store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []})
with store.at(world["system"]), pytest.raises(store.StoreError) as e:
store.delete("promote", sp)
assert e.value.status == 409 and "개인 7/42" in e.value.detail
(world["personal"] / "labor" / "x1.json").unlink()
with store.at(world["system"]):
store.delete("promote", sp)
assert not (world["system"] / "promote" / f"{sp}.json").exists()
def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
sp = _promote(world["personal"])
sys_sp = _promote(world["system"])
sc = _combo(
world["personal"],
[{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}],
)
with store.at(world["personal"]):
doc = store.read("precombo", sc)["문서"]
with store.at(world["company"]):
made = refs.create(
"precombo", refs.carry({**doc, "본뜬": {**MINE, "키": sc}}, ("personal", "7/42"))
)
got = _read(world["company"], "precombo", made["이름"])
assert got["본뜬"] == {**MINE, "키": sc}
assert [one["참조"] for one in got["담은로직"]] == [
{**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키
{**SYS, "키": sys_sp}, # 마스터는 그대로
]
assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp}
def test_길_새로_복사_권한(world):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Router as labor_router
app = FastAPI()
app.include_router(labor_router.router)
app.dependency_overrides[verify_session] = lambda: {
"user_id": 42,
"company_id": 7,
"role": "USER",
"is_master": False,
}
client = TestClient(app)
url = "/api/m02/layers/{}/library/{}"
body = {"문서": {"담은로직": [{"로직": TEN}]}}
got = client.post(url.format("personal", "promote"), json=body).json()
assert got["이름"] == "SP-000001"
assert client.post(url.format("system", "promote"), json=body).status_code == 403
copy = {"본뜬": {**MINE, "키": "SP-000001"}}
assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002"
other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}}
assert client.post(url.format("personal", "promote"), json=other).status_code == 403
def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world):
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Item_Source as source
sp = _promote(world["system"])
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}])
ref = {**SYS, "키": sc}
with store.at(world["system"]):
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]})
live = labor.calc(doc, {})["줄"][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]
bundle = prices.to_json(prices.collect([TEN]))
for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄
kind = refs.kind_of(key)
(world["system"] / kind / f"{key}.json").unlink()
got = prices.calc(rows, bundle, {}, {}, None)
line = got["줄"][0]
assert line["ok"] and line["참조"] == ref
assert line["계"] == pytest.approx(live, rel=1e-9)
assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9)
def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world):
from M01_MasterData import M01_MasterData_Store_Cache as cache
sp = _promote(world["system"], STONE)
with store.at(world["system"]):
have = store.read("labor", "rv")
line = {"참조": {**SYS, "키": sp}}
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]})
class Gone: # STONE 을 지운 마스터
def logic(self, code):
if code == STONE:
raise cache.cm.mf.FormulaError("없음")
got = cache.labor_errors(Gone())
assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"]
def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path):
_promote(world["system"])
got = layers.seed_project(tmp_path / "p1")
assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"])
def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world):
sp = _promote(world["system"])
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋")
rows = [
{"종류": "promote", "이름": sp},
{"종류": "precombo", "이름": sc},
{"종류": "labor", "이름": "rv"},
]
refs.summaries(world["system"], rows)
one = float(Decimal(str(_base(TEN)["계"])) / 10)
assert rows[0]["기준금액"] == pytest.approx(one)
assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3))
assert "기준금액" not in rows[2]