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

This commit is contained in:
2026-11-01 15:21:30 +09:00
70 changed files with 3696 additions and 619 deletions
@@ -1,10 +1,14 @@
"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자.
"""197-1 · 716-1 대시보드 「구조물 옵션 정의」 줄 → M01 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 ORCA.
716-1 — 화면 입구가 M02 에서 M01 로 옮겨감(화면 코드 · 서버 길은 M02 폴더 그대로) · 옛 M02 `open=옵션` 주소는
시스템 관리자면 M01 `?open=옵션` 으로 돌려보냄.
원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가
모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음).
· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림
· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함
· 줄 → 해시(M01 open=로직 · 옵션 / M02 open=일위대가조합 · 구조물조합 · 도면) · 해시 → 진입(`entryFromHash`) Node
· M02 `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함 · M01 `reveal` 도 같은 꼴
· 옛 `open=옵션` 돌려보냄(`redirectOldOptions`)
· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음
"""
@@ -41,16 +45,20 @@ def _run(script: str, *args: str) -> object:
@needs_node
def test_카드_셋_해시():
def test_줄_해시_옵션은_M01():
from test_b01_settings_cards import _cards
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
assert [c[2].split("?")[1].split("&")[0] for c in cards[1:4]] == [
"open=구조물조합",
"open=옵션",
"open=도면",
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
assert heads == [
"m01-master-data?open=로직",
"m01-master-data?open=옵션",
"m01-master-data?open=별칭", # 718-3
"m02-master-template?open=일위대가조합",
"m02-master-template?open=구조물조합",
"m02-master-template?open=도면",
]
assert all("&root=master" in c[2] for c in cards[1:4])
assert all("&root=master" in c[2] for c in cards[4:7])
@needs_node
@@ -69,7 +77,7 @@ def test_해시_진입_셋():
+ fn.group(0).replace("export function", "function")
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
)
for want in ("구조물조합", "옵션", "도면"):
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
got = _run(script, f"open={want}&root=master&company=3")
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
@@ -139,3 +147,38 @@ def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리
assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림
assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음
assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림
@needs_node
def test_옛_옵션_주소_M01로_돌려보냄():
layer = _text(M02 / "M02_MasterTemplete_Layer.ts")
fn = re.search(r"^export function redirectOldOptions\([\s\S]*?^}", layer, re.M)
assert fn, "redirectOldOptions 를 못 찾음"
script = (
"const currentQuery = () => new URLSearchParams(process.argv[1]);\n"
"const ROUTES = { M01_MASTER_DATA: 'm01' }; const sent: string[] = [];\n"
"const navigateTo = (r: string, q: any) => sent.push(r + JSON.stringify(q));\n"
"type SessionUser = any;\n"
+ fn.group(0).replace("export function", "function")
+ "\nconst a = redirectOldOptions({ role: 'SYSTEM_ADMIN' });"
+ "\nconst b = redirectOldOptions({ role: 'USER' });"
+ "\nconst c = redirectOldOptions(null);"
+ "\nconsole.log(JSON.stringify([a, b, c, sent]));"
)
assert _run(script, "open=옵션&root=master") == [True, False, False, ['m01{"open":"옵션"}']]
# 옵션이 아니면 안 돌려보냄
assert _run(script, "open=도면&root=master") == [False, False, False, []]
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "if (redirectOldOptions(user))" in page
assert 'mountSettingsDrawer(user ?? {}, "m02", entry.company);' in page
def test_M01_open_인자_로직_옵션():
m01 = ROOT / "M01_MasterData"
page = _text(m01 / "M01_MasterData_UI_Page.ts")
assert 'if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);' in page
assert "setLeaveGuard(leaveOptions)" in page # 저장 안 한 정의 고침 — 떠날 때 물음
side = _text(m01 / "M01_MasterData_UI_Side.ts")
assert "buildOptionsSection({ ...optionsOpt, isAdmin: true })" in side
# 로직 칸 아래 구분선 부품 + 옵션 칸
assert "logic, createDivider(), optionsSec, aliasSec" in side # 718-3 별칭 장부 칸
+1 -1
View File
@@ -263,7 +263,7 @@ def test_카드_시스템_관리자만_글_다_있음():
links = nav.split("export function mountSettingsDrawer")[0]
admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =")
assert 'key: "calcSites"' in admin and "calcSites" not in rest
assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav
assert "calcSites" not in nav.split("export function pageLinks")[1] # ☰ 는 페이지 둘만(716-2)
page = _text("B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts")
assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page
locale = _text("ui_template/ui_template_locale_m1.ts")
+4 -2
View File
@@ -94,8 +94,10 @@ def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
assert master_hash() == before
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
# 718-1 — 붙여넣기 실무자료 = 지문 없음 → 불러오기 목록엔 숨김 · 파일은 남음
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
doc = client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").json()
assert doc["파일"].startswith("붙여넣기") and doc["시트"] == "붙여넣기"
def _block(rows, no):
+5 -1
View File
@@ -211,6 +211,9 @@ def test_못_잇는_짝(geo):
)
def test_기억_층(tmp_path, monkeypatch, role, layer):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system")
master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2)
hopyo_store.remember(tmp_path / "system", master, lk.MEMORY)
session = {"company_id": 7, "user_id": 3, "role": role}
assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer
assert route.keep(session, {}) is None
@@ -220,7 +223,8 @@ def test_기억_층(tmp_path, monkeypatch, role, layer):
got = route.recalled(session)
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
assert got["b||"]["확정"] == "x"
assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음
assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층)
assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만
def _same(a, b):
+77
View File
@@ -0,0 +1,77 @@
"""709 쪽 단추 부품 지킴 시험 — 쪽 수 · 화면 높이 → 줄 수 · 쪽 크기 바뀔 때 첫 줄 유지(순수 함수).
`ui_template/ui_template_pager.ts` 의 「순수 셈」 구간만 떼어 node 로 돌림(화면 · 서버 없음).
부품 모음 견본 · 옛 이름 `buildPager` 가 새 부품을 부르는지도 글로 잡음.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
PAGER = ROOT / "ui_template" / "ui_template_pager.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _pure() -> str:
text = PAGER.read_text(encoding="utf-8")
start = text.index("export const ROW_H")
end = text.index("/* ── 아래부터 화면 재기")
return text[start:end]
def _run(body: str) -> object:
script = _pure() + "\n" + body
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
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_쪽_수():
got = _run(
"console.log(JSON.stringify([pageCount(0,30), pageCount(30,30), pageCount(31,30), pageCount(1469,30)]));"
)
assert got == [1, 1, 2, 49]
def test_화면_높이_줄_수():
# 머리 40 + 줄 40 · 표 칸 위 200 · 쪽 단추 자리 64 → 1080: (816-40)/40=19 · 768: (504-40)/40=11
got = _run(
"console.log(JSON.stringify(["
"fitRows(1080-200-PAGER_ROOM), fitRows(768-200-PAGER_ROOM), "
"fitRows(100), fitRows(100000), fitRows(Number.NaN), fitRows(520, 36, 40)]));"
)
assert got == [19, 11, 5, 200, 5, 13]
def test_높이가_클수록_줄이_많음():
got = _run(
"const a=[]; for (let h=300; h<=1400; h+=50) a.push(fitRows(h)); "
"console.log(JSON.stringify(a.every((v,i)=>i===0||v>=a[i-1])));"
)
assert got is True
def test_쪽_크기_바뀌어도_첫_줄_유지():
# 30줄 쪽의 3쪽(첫 줄 61번째) → 20줄 쪽이면 4쪽(61~80) · 50줄 쪽이면 2쪽(51~100)
got = _run(
"console.log(JSON.stringify([pageKeepFirst(3,30,20), pageKeepFirst(3,30,50), pageKeepFirst(1,30,12)]));"
)
assert got == [4, 2, 1]
def test_옛_이름은_새_부품():
cells = (ROOT / "M01_MasterData" / "M01_MasterData_UI_Cells.ts").read_text(encoding="utf-8")
assert re.search(r"export const buildPager = createPager;", cells)
assert "createButton" not in cells
parts = (ROOT / "ui_template" / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8")
assert 'file: "ui_template_pager.ts"' in parts and 'file: "ui_template_divider.ts"' in parts
+179
View File
@@ -0,0 +1,179 @@
"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름."""
from decimal import Decimal
import pytest
from M02_MasterTemplete import M02_Sangeun_Doc as sd
ROW = {
"키": "GX000001",
"결과단위": "원/㎡",
"입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}],
"호표": [
{
"종류": "인력",
"요소": "LB000033",
"이름": "석공",
"수량": "높이 * 0.1",
"비목": "노무비",
},
{"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"},
{"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"},
{"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"},
{
"종류": "로직",
"요소": "로직(GC000276, 기계='5401-0017')",
"이름": "굴착기",
"수량": "0.5",
},
],
"덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}],
}
def _doc(**extra):
return {
"키": "SK-000001",
"이름": "돌쌓기 내 값",
"단위": "㎡",
"바탕": {"로직": "GX000001"},
**extra,
}
def test_line_ids_kind_key_n():
assert sd.line_ids(ROW) == [
"labor:LB000033#0",
"material:모래#0",
"labor:LB000033#1",
"material:자갈#0",
"logic:GC000276#0",
"pct:공구손료#0",
]
def test_clean_shape_and_refusals():
got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW)
assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == []
for bad, why in (
({"키": "SC-000001"}, "SK-"),
({"이름": " "}, "이름"),
({"단위": ""}, "단위"),
({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"),
({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"),
({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"),
({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"),
({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"),
({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"),
({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"),
({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"),
):
with pytest.raises(sd.DocError, match=why):
sd.clean(_doc(**bad), ROW)
# 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림
got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW)
assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}}
def test_open_inputs_only_ratio():
with pytest.raises(sd.DocError, match="배율만"):
sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW)
got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW)
assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1}
def test_patched_cover_qty_price_ratio():
doc = sd.clean(
_doc(
덮개={
"labor:LB000033#1": {"qty": 0.25, "labor": 200000},
"labor:LB000033#0": {"배율": 1.5},
"logic:GC000276#0": {"expense": 1000},
"pct:공구손료#0": {"qty": 5},
}
),
ROW,
)
got = sd.patched(ROW, doc)
rows = got["줄"]["호표"]
assert rows[0]["수량"] == "(높이 * 0.1) * 1.5"
assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000)
assert rows[4]["값묶음"] == {"경비": Decimal(1000)}
assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100"
assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜
def test_drop_keeps_pick_on_its_line():
"""뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음."""
doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW)
got = sd.patched(ROW, doc)
names = [x["이름"] for x in got["줄"]["호표"]]
assert names == ["석공", "석공", "자갈", "굴착기"]
assert got["고름"] == {"2": "MT000010"}
assert got["정체"][:4] == [
"labor:LB000033#0",
"labor:LB000033#1",
"material:자갈#0",
"logic:GC000276#0",
]
def test_added_lines_before_extras():
doc = sd.clean(
_doc(
더한줄=[
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1},
{
"종류": "로직",
"로직": "GC000001",
"입력값": {"규격": "가", "두께": 2},
"수량": 1,
},
{"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2},
]
),
ROW,
)
got = sd.patched(ROW, doc)
added = got["줄"]["호표"][5:]
assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1"
assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)"
assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None
assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"]
def test_lost_covers_kept_and_listed():
"""짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름."""
doc = sd.clean(
_doc(
덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}},
고름={"labor:LB000033#1": "MT000010"},
뺀줄=["material:없음#0"],
),
ROW,
)
got = sd.patched(ROW, doc)
why = {x["cid"]: x["까닭"] for x in got["짝잃음"]}
assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"]
assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄")
assert "labor:LB999999#0" in doc["덮개"]
def test_chain_moves_base_picks():
"""바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에."""
first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW))
child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"])
got = sd.patched(first["줄"], child, first["고름"])
assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈"
def test_empty_base():
row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"})
doc = sd.clean(
{"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None,
"더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]},
row,
) # fmt: skip
got = sd.patched(row, doc)
assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식"
+191
View File
@@ -0,0 +1,191 @@
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage.
고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 ·
순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값.
"""
from decimal import Decimal
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘(검색어)
def _sk(folder, **doc):
with store.at(folder):
return refs.create("sangeun", {"이름": "내 단가산출", "단위": "㎡", "더한줄": [], **doc})[
"이름"
]
def _doc(folder, key):
with store.at(folder):
return store.read("sangeun", key)["문서"]
def _calc(folder, key, given=None):
with store.at(folder):
return calc.calc_doc(_doc(folder, key), given)
def _whole():
return m01.loaded()[1]
def _bare(code):
from M02_MasterTemplete import M02_Labor_Doc as labor
return labor.run_line(_whole(), code, {})
def test_qty_cover_hand_and_pct_recomputed(world):
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값."""
bare = _bare(PCT)
first, second = bare["줄"][0], bare["줄"][1]
key = _sk(
world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5, "원값": 9}}
)
doc = _doc(world["personal"], key)
assert doc["덮개"]["labor:LB000036#0"]["원값"]["qty"] == pytest.approx(float(first["수량"]))
assert len(doc["바탕판"]) == 16
got = _calc(world["personal"], key)
labor_sum = Decimal("0.5") * Decimal(str(first["단가"])) + Decimal(str(second["금액"]))
assert got["기준"]["노무비"] == pytest.approx(float(labor_sum))
assert got["기준"]["경비"] == pytest.approx(float(labor_sum * 3 / 100), abs=1)
assert got["줄"][0]["덮개"] == ["qty"] and got["줄"][0]["cid"] == "labor:LB000036#0"
assert got["짝잃음"] == []
# 덧줄 율 덮개 = 5 %
key = _sk(
world["personal"],
바탕={"로직": PCT},
덮개={"pct:" + bare["줄"][2]["이름"] + "#0": {"qty": 5}},
)
got = _calc(world["personal"], key)
assert got["기준"]["경비"] == pytest.approx(float(bare["기준"]["노무비"]) * 0.05, abs=1)
def test_drop_then_pick_stays_on_its_line(world):
"""뺀줄 뒤 고름 제자리 — 둘째 재료 줄을 고른 자재가 첫 재료 줄을 뺀 뒤에도 그 줄 품목."""
import master_pick as mp
whole = _whole()
row = whole.logic(TWO_MATS)
item = row["호표"][2]
picks = mp.search(whole, item["요소"], {}, item.get("단위"))
want = picks[1] if len(picks) > 1 else picks[0]
first_cid = "material:" + row["호표"][1]["이름"] + "#0"
second_cid = "material:" + item["이름"] + "#0"
key = _sk(
world["personal"],
단위="m",
바탕={"로직": TWO_MATS},
뺀줄=[first_cid],
고름={second_cid: want},
자재표시={second_cid: {"별도계상": "직접계산"}}, # 기본 = 별도계상(222-1) 줄 빠짐
)
got = _calc(world["personal"], key)
names = [x["이름"] for x in got["줄"]]
assert row["호표"][1]["이름"] not in names
line = next(x for x in got["줄"] if x["cid"] == second_cid)
assert line["품목"]["키"] == want
def test_lost_cover_kept_and_listed(world):
key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB999999#0": {"qty": 1}})
got = _calc(world["personal"], key)
assert got["짝잃음"][0]["cid"] == "labor:LB999999#0"
assert "labor:LB999999#0" in _doc(world["personal"], key)["덮개"]
assert got["기준"]["계"] == pytest.approx(float(_bare(PCT)["기준"]["계"]))
def test_chain_loop_422_and_delete_409(world):
"""바탕 사슬 A ← B · B 를 A 의 바탕으로 = 422 · 쓰는 곳 있는 A 지우기 = 409(이름)."""
a = _sk(world["personal"], 바탕={"로직": PCT})
b = _sk(
world["personal"], 바탕={"참조": {**MINE, "키": a}}, 덮개={"labor:LB000002#0": {"qty": 0}}
)
got = _calc(world["personal"], b)
bare = _bare(PCT)
assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"]))
with store.at(world["personal"]):
doc = store.read("sangeun", a)
with pytest.raises(store.StoreError) as e:
store.write(
"sangeun", a, doc["판"], {**doc["문서"], "바탕": {"참조": {**MINE, "키": b}}}
)
assert e.value.status == 422
with pytest.raises(store.StoreError) as e:
store.delete("sangeun", a)
assert e.value.status == 409 and "단가산출 개인 7/42" in str(e.value.detail)
def test_precombo_holds_sangeun(world):
"""사전조합 참조 줄이 SK 를 담음 — 1 단위 값 × 수량 · SK 지우기 막음."""
from M02_MasterTemplete import M02_Labor_Doc as labor
sk = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}})
with store.at(world["personal"]):
sc = refs.create(
"precombo",
{"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]},
)["이름"]
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
with pytest.raises(store.StoreError) as e:
store.delete("sangeun", sk)
one = _calc(world["personal"], sk)["1단위"]["계"]
assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1)
assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail)
def test_layer_copy_round_trip(world):
"""개인 B(바탕 = 개인 A) → 회사로 복사 = A 도 같이(새 키 · 본뜬) → 개인으로 다시 = 같은 값."""
a = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}})
b = _sk(world["personal"], 바탕={"참조": {**MINE, "키": a}}, 뺀줄=["labor:LB000002#0"])
want = _calc(world["personal"], b)["기준"]
src = _doc(world["personal"], b)
with store.at(world["company"]):
made = refs.create(
"sangeun", refs.carry(refs.origin_mark(src, {**MINE, "키": b}), ("personal", "7/42"))
)
copy = _doc(world["company"], made["이름"])
assert copy["바탕"]["참조"]["층"] == "company" and copy["본뜬"]["키"] == b
assert _calc(world["company"], made["이름"])["기준"] == want
with store.at(world["personal"]):
back = refs.create(
"sangeun",
refs.carry(refs.origin_mark(copy, {**COMPANY, "키": made["이름"]}), ("company", "7")),
)
assert _doc(world["personal"], back["이름"])["바탕"]["참조"]["층"] == "company"
assert _calc(world["personal"], back["이름"])["기준"] == want
def test_refusals(world):
with store.at(world["personal"]):
for bad, status in (
({"이름": "x", "더한줄": []}, 400), # 단위 빔
({"이름": "x", "단위": "㎡", "더한줄": [], "바탕": {"로직": "GC999999"}}, 400),
(
{
"이름": "x",
"단위": "㎡",
"더한줄": [],
"바탕": {"참조": {**MINE, "키": "SK-000999"}},
},
400,
),
({"이름": "x", "단위": "㎡"}, 400), # 뼈대(더한줄) 없음
):
with pytest.raises(store.StoreError) as e:
refs.create("sangeun", bad)
assert e.value.status == status, bad
key = _sk(world["personal"], 바탕={"로직": PCT})
with store.at(world["personal"]), pytest.raises(store.StoreError) as e:
calc.calc_doc({**_doc(world["personal"], key), "바탕": {"참조": {**MINE, "키": key}}})
assert e.value.status == 422
@@ -0,0 +1,67 @@
"""일위대가 불러오기 한시(PLAN 718-1) — 올림 지문 · [끝내기] 지움 · 겹침 막음.
같은 엑셀 작업 중 다시 올림 = 그 파일 이어서(새 키 없음) · [끝내기] = HP · 격자 · 맞춤셈 캐시 0 ·
만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 409 · 만든 것 없이 지움 = 다시 올림 됨 ·
옛 HP(지문 없음) = 파일 그대로 목록에서만 숨김 · 키 번 = 지운 뒤에도 다시 안 씀. 층 = 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401
ALL = "/api/m02/layers/personal/hopyo-build-all"
def _keys(client):
return [r["키"] for r in client.get(URL.format("personal")).json()["줄"]]
def test_이어서_끝내기_지움_겹침_막음(client):
folder = layers.personal_dir(7, 42)
first = _post(client).json()
assert first["키"] == "HP-000001" and len(first["지문"]) == 64
again = _post(client).json()
assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음
assert _keys(client) == ["HP-000001"]
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
assert len(made["만듦"]) == 68
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
cache = fit.cache_path(folder, "HP-000001")
cache.write_text("{}", encoding="utf-8")
old_cache = cache.with_name("_".join(cache.name.split("_")[:2]) + "_옛판_1.json")
old_cache.write_text("{}", encoding="utf-8")
sc_before = sorted((folder / "precombo").glob("SC-*.json"))
res = client.delete(URL.format("personal") + "/HP-000001")
assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68}
assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0
assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json"))
assert not cache.exists() and not old_cache.exists()
assert hs.memory(folder) == {"장부 줄": {"갈래": "안 씀"}}
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before # 만든 일위대가 그대로
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
assert "일위대가 68" in block.json()["detail"] and _keys(client) == []
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
def test_만든_것_없이_지움_다시_올림_키_번_안_되씀(client):
assert _post(client).json()["키"] == "HP-000001"
assert client.delete(URL.format("personal") + "/HP-000001").json()["만든"] == 0
got = _post(client) # 만든 것 없음 = 막지 않음 · 번은 이어서
assert got.status_code == 200 and got.json()["키"] == "HP-000002"
def test_옛_HP_목록에서만_숨김(client):
folder = layers.personal_dir(7, 42) / hs.DIRNAME
folder.mkdir(parents=True)
old = folder / "HP-000007.json"
old.write_text(json.dumps({"키": "HP-000007", "파일": "옛.xls", "호표": []}), encoding="utf-8")
assert _keys(client) == []
assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == []
assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로
assert old.is_file() and _keys(client) == ["HP-000008"]
+134
View File
@@ -0,0 +1,134 @@
"""별칭 장부 시스템 모으기(PLAN 718-2 · `M01_MasterData_Alias`) — 회사 · 개인 층 맞춤 +
내역맞춤 모음 · 같은 짝 묶기 · 회사 수 · 사람 수(익명) · 충돌 · 승인 → 마스터 줄 ·
반려 기억 · 다른 회사 새 사용자가 마스터 줄을 씀
· 시스템 관리자만 · master_data 원본 그대로. 층 · storage 는 임시 폴더.
"""
import json
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Bill_Link as lk
from B09_Estimation import B09_Estimation_Router_Link as route
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Alias as alias
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당(임시 storage · 마스터 층)을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
DIG_KEY = "구조물터파기(인력100%)|토사|m3"
FORM_KEY = "합판거푸집|4회|m2"
CUT_KEY = "토사절취||m3"
ROOT = Path(__file__).resolve().parents[2]
def _pair(code):
return ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": code, "배": 1}]})
def _seed():
"""회사 7(개인 42 · 43 = 같은 짝) · 회사 8(개인 50 = 다른 짝 · 회사 층 = 안 씀) · 내역맞춤."""
for user, who in ((42, "갑"), (43, "을")):
mine = layers.personal_dir(7, user)
ledger.write(mine, {DIG_KEY: _pair("GF000101")}, {"id": user, "이름": who}, "가공사.xls")
ledger.write(layers.personal_dir(8, 50), {DIG_KEY: _pair("GF000103")}, {"id": 50}, "나.xls")
skip = ledger.check(FORM_KEY, {"갈래": "안 씀"})
ledger.write(layers.company_dir(8), {FORM_KEY: skip}, {"id": 51, "이름": "병"}, "다.xls")
live = {"확정": "earth:4", "이름": "절토(토사)"}
hs.remember(layers.personal_dir(7, 42), {CUT_KEY: live, "a||": {lk.FORGOT: True}}, lk.MEMORY)
hs.remember(layers.company_dir(8), {CUT_KEY: live}, lk.MEMORY)
def _one(got, kind, key, code=None):
rows = [x for x in got["후보"] if (x["종류"], x["열쇠"]) == (kind, key)]
if code:
rows = [x for x in rows if x["짝"][0]["로직"] == code]
assert len(rows) == 1, rows
return rows[0]
def test_모음_묶기_익명_충돌(client):
before = master_hash()
_seed()
got = alias.gather()
assert got["모은곳"] == {"회사": 2, "사람": 5} and got["반려"] == 0
assert len(got["후보"]) == 4 # 터파기 둘(충돌) · 거푸집 안 씀 · 토사절취 내역맞춤(같은 짝 하나)
same = _one(got, alias.ALIAS, DIG_KEY, "GF000101")
assert (same["회사"], same["사람"], same["충돌"], same["마스터"]) == (1, 2, True, None)
assert _one(got, alias.ALIAS, DIG_KEY, "GF000103")["사람"] == 1
form = _one(got, alias.ALIAS, FORM_KEY)
assert (form["갈래"], form["충돌"], form["사람"]) == ("안 씀", False, 1)
cut = _one(got, alias.BILL, CUT_KEY)
assert (cut["회사"], cut["사람"], cut["확정"], cut["충돌"]) == (2, 2, "earth:4", False)
text = json.dumps(got, ensure_ascii=False)
for secret in ("갑", "을", "병", "가공사.xls", "누가", "처음파일", "주인"):
assert secret not in text # 익명 — 회사 수 · 사람 수만
assert master_hash() == before
def test_승인_마스터_줄_반려_기억(client, tmp_path, monkeypatch):
before = master_hash()
_seed()
newbie = layers.personal_dir(9, 99) # 처음 쓰는 다른 회사 사용자
assert ledger.recall(DIG, newbie) is None
sig = _one(alias.gather(), alias.ALIAS, DIG_KEY, "GF000101")["서명"]
got = alias.approve(alias.ALIAS, DIG_KEY, sig, {"id": 1, "이름": "관리자"})
hit = ledger.recall(DIG, newbie)
assert (hit["층"], hit["짝"][0]["로직"]) == ("system", "GF000101") # 승인 → 모두가 씀
master = ledger.read(client.system)
assert master[DIG_KEY]["처음파일"] is None # 마스터 줄에도 남의 파일 이름 없음
other = _one(got, alias.ALIAS, DIG_KEY) # 승인한 짝 = 마스터와 같음 → 후보에서 빠짐
assert other["짝"][0]["로직"] == "GF000103"
assert (other["마스터"], other["충돌"]) == ("다름", True)
got = alias.reject(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert got["반려"] == 1 and not [x for x in got["후보"] if x["열쇠"] == DIG_KEY]
assert alias.gather()["반려"] == 1 # 반려 기억 = 다시 모아도 안 보임
with pytest.raises(alias.store.StoreError) as error:
alias.approve(alias.ALIAS, DIG_KEY, other["서명"], {"id": 1})
assert error.value.status == 404
with pytest.raises(alias.store.StoreError):
alias.approve("모름", DIG_KEY, sig, {"id": 1})
# 내역맞춤 승인 → 마스터 기억 → 다른 회사 새 사용자 B09 잇기 기억에 나옴
cut = _one(alias.gather(), alias.BILL, CUT_KEY)
alias.approve(alias.BILL, CUT_KEY, cut["서명"], {"id": 1})
assert [r["열쇠"] for r in alias.master_bill()] == [CUT_KEY]
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 9, "user_id": 99, "role": "USER"}
assert route.recalled(user)[CUT_KEY]["확정"] == "earth:4"
assert alias.forget_bill(CUT_KEY) == [] and CUT_KEY not in route.recalled(user)
assert master_hash() == before
def test_길_시스템_관리자만(client):
_seed()
app = FastAPI()
app.include_router(alias.router, dependencies=[Depends(require_system_admin)])
who = {"user_id": 1, "company_id": None, "role": "USER", "name": "갑"}
app.dependency_overrides[verify_session] = lambda: who
web = TestClient(app)
for role in ("USER", "ADMIN"):
who["role"] = role
assert web.get("/api/m01/alias-ledger").status_code == 403
who["role"] = "SYSTEM_ADMIN"
got = web.get("/api/m01/alias-ledger").json()
assert len(got["후보"]) == 4 and got["마스터내역"] == []
form = _one(got, alias.ALIAS, FORM_KEY)
pick = {"종류": alias.ALIAS, "열쇠": FORM_KEY, "서명": form["서명"]}
res = web.post("/api/m01/alias-ledger/approve", json=pick)
assert res.status_code == 200 and len(res.json()["후보"]) == 3
assert ledger.read(client.system)[FORM_KEY]["갈래"] == "안 씀"
assert web.post("/api/m01/alias-ledger/approve", json=pick).status_code == 404
assert web.post("/api/m01/alias-ledger/reject", json={**pick, "서명": "x"}).status_code == 422
gone = web.post("/api/m01/alias-ledger/master-bill/forget", json={"열쇠": CUT_KEY})
assert gone.status_code == 404
main = (ROOT / "main.py").read_text(encoding="utf-8")
at = main.index("M01_MasterData.M01_MasterData_Alias")
assert "dependencies=system_admin_only" in main[at : at + 120]
+63 -15
View File
@@ -58,7 +58,36 @@ def _cards(user: dict, company_id: int | None = None) -> list:
return json.loads(result.stdout)
def _pages(user: dict, company_id: int | None = None) -> list:
# ☰ 메뉴 = pageLinks(settingLinks 의 구조물 조합 줄을 바탕으로 페이지 둘만)
script = (
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
" M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n"
"const L = (k: string) => k;\n"
"let went = '';\n"
"const navigateTo = (r: string, q?: Record<string, string>) =>"
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
+ _fn(_text(NAV), "settingLinks")
+ "\n"
+ _fn(_text(NAV), "pageLinks")
+ "\nconst user = JSON.parse(process.argv[1]);"
+ "\nconst links = pageLinks(user, JSON.parse(process.argv[2]));"
+ "\nconsole.log(JSON.stringify(links.map((l: any) => {"
+ " navigateTo(l.route, l.query); return [l.key, l.title, decodeURIComponent(went)]; })));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
M01 = "m01-master-data"
M02 = "m02-master-template"
READ_SRC = "B01_Dashboard_PermSourceRead"
WRITE_M = "B01_Dashboard_PermMasterWrite"
# 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창
CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"]
READ_CO = (
@@ -68,21 +97,42 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_다섯_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite"
def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시():
# 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 ·
# 별칭 장부) +
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
["M01_Title", READ_SRC, M01],
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"],
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == (
f"{M02}?open=구조물조합&root=master"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_메뉴_페이지_둘만_역할별():
# 716-2 — ☰ = M01 · M02 두 페이지(시스템 관리자) / M02 한 페이지(그 밖) · 열쇠 m01 · m02
admin = _pages({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
assert admin == [
["m01", "B01_Dashboard_MasterElements", M01],
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal&company=3"],
]
assert _pages({"role": "ADMIN", "is_master": False, "company_id": None}) == [
["m02", "M02_Title", f"{M02}?open=구조물조합&root=company"],
]
assert _pages({"role": "USER", "is_master": False, "company_id": 1}) == [
["m02", "M02_Title", f"{M02}?open=구조물조합&root=personal"],
]
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_관리자_개인_카드_하나():
company = [
@@ -191,14 +241,12 @@ def test_회사_찾기_거르기():
def test_구조물_조합_기본_층_개인():
"""123-1 — 회사 있는 사람은 역할 무관 구조물 조합 = 개인 · 회사 없으면 지금 규칙 · 다른 곳 그대로 ·
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
m = "B01_Dashboard_PermMasterWrite"
combo = f"{M02}?open=구조물조합&root=personal"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardStructCombo", m, f"{combo}&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
assert rows[4:7] == [
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
]
assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [
["B01_Dashboard_CardStructCombo", WRITE_CO, combo],
@@ -206,7 +254,7 @@ def test_구조물_조합_기본_층_개인():
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
# 개인 층 없음(회사 없음) = 지금 규칙
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[1][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == (
f"{M02}?open=구조물조합&root=master"
)
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
+3 -1
View File
@@ -179,8 +179,10 @@ def test_올리기_목록_읽기_지우기(client):
assert (folder / hs.DIRNAME / "HP-000001.json").is_file()
assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임
assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo"))
assert _post(client).json()["키"] == "HP-000002"
again = _post(client).json() # 718-1 — 작업 중 같은 엑셀 = 그 파일 이어서
assert again["키"] == "HP-000001" and again["이어서"] is True
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert _post(client).json()["키"] == "HP-000002" # 지운 번 다시 안 씀
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"]
assert client.get(URL.format("personal") + "/HP-000001").status_code == 404
assert client.get(URL.format("personal") + "/../x").status_code == 404
+5 -3
View File
@@ -105,8 +105,8 @@ def test_끝까지_확인한_줄만_별칭(client):
keys = {r["열쇠"]: r for r in rows}
assert {p["로직"] for p in keys[rebar["맞춤"]["열쇠"]]["짝"]} == {"GC000206", "GC000207"}
# 다시 올림 = 확인한 줄은 별칭 · 체크 · 만든 것이 보임
_up(client, "HP-000002")
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000002", no)["줄"]}
assert _post(client).json()["이어서"] is True # 718-1 — 같은 엑셀 = 그 파일 이어서(새 키 없음)
again = {x["이름"]: x["맞춤"] for x in _one(client, "HP-000001", no)["줄"]}
assert again["레미콘타설(장비사용)"]["상태"] == "별칭" and again["레미콘타설(장비사용)"]["체크"]
assert again["철근가공조립(현장)"]["상태"] == "별칭"
assert again["철근가공조립(현장)"]["대상"][0]["출처"] == {
@@ -138,7 +138,9 @@ def test_막는_길(client):
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
)
assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3)
assert (
empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0
) # 빈 몸 = 모두 고정(705-3)
money = client.post(
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},
+4 -2
View File
@@ -84,13 +84,15 @@ def test_올리기_격자_200_404_지우기(client):
path.unlink() # 옛 파일(324) = 격자 없음
old = client.get(GRID.format("personal", "HP-000001"))
assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임"
assert _post(client).status_code == 200
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert _post(client).status_code == 200 # 718-1 — 같은 엑셀은 지운 뒤 새 번
assert client.get(GRID.format("personal", "HP-000002")).status_code == 200
assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200
assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists()
assert client.get(GRID.format("personal", "HP-000002")).status_code == 404
assert client.get(GRID.format("personal", "../x")).status_code == 404
assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘
gone = client.get(URL.format("personal") + "/HP-000001/3") # 길 안 부딪힘(718-1 지운 뒤 = 없음)
assert gone.status_code == 404 and "없는 실무자료" in gone.json()["detail"]
assert master_hash() == before
+22 -22
View File
@@ -29,6 +29,7 @@ NEW = (
"Hopyo_CheckRows",
"Hopyo_Choose",
"Hopyo_Ledger",
"Hopyo_Wizard",
)
ROW = {
"층": "system",
@@ -301,7 +302,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
"근거 맞음 모두 확정",
'"확정"',
'"사전조합 만들기"',
"불러오기",
'"불러오기" 옛 단추', # 718-1 — 「불러오기」 = 칸 이름 · 마법사 단추라 낱말은 뺌
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
@@ -342,20 +343,18 @@ def test_좌측_판_자리와_권한():
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
assert "buildHopyoView" not in hopyo_side
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
assert (
"onClose:" in hopyo_side
and "onDeleted: opt.canWrite ? () => void refresh() : undefined," in hopyo_side
)
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
assert 'L("M02_HopyoImport")' in hopyo_side and "일위대가 실무자료" not in hopyo_side
start = hopyo_side.split("const start = createButton({")[1].split("start.title")[0]
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 hopyo_side.index("list, ledger]") > 0
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 # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬
def test_로직_추가_한_곳과_파일_규칙():
@@ -379,9 +378,9 @@ def test_로직_추가_한_곳과_파일_규칙():
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert "await showSheet(got);" in side # 올린 직후 자동
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
assert "second(pick, got);" in side # 올린 직후 ② 걸음
assert lazy in side.split("function second(")[1].split("async function end(")[0]
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check
assert "엑셀에서 보기" not in check
@@ -517,6 +516,7 @@ def test_명사형_단추와_별칭_장부_풀이_359():
assert f"label: {label}" in ledger, label
assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger
mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger")
mine += ("Hopyo_Wizard",)
every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine)
for gone in (
"고친 것 버림",
@@ -532,8 +532,8 @@ def test_명사형_단추와_별칭_장부_풀이_359():
def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
open_ = side.split("sheet.openHopyoSheet({")[1].split("});")[0]
assert 'target: { layer: "personal", owner: null },' in open_
assert "canWrite: true," in open_ # 만들기 = 개인 층 · 화면 층 무관
assert "onDeleted: opt.canWrite ? () => void refresh() : undefined," in open_
"""718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음."""
side = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0]
assert "target: scope," in open_ and "canWrite: true," in open_
assert "onDeleted: () => void first()," in open_
+1 -1
View File
@@ -85,7 +85,7 @@ def _base(code):
# ── 자료 틀 ────────────────────────────────────────────────────────────
def test_단가승격_서고는_없음_층_종류에서_뺌(world):
assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",)
assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo", "sangeun")
with store.at(world["system"]), pytest.raises(KeyError):
refs.create("promote", {"담은로직": [{"로직": TEN}]})
+18 -1
View File
@@ -82,7 +82,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
def test_고름에_owner_권한_서고_길에_명시_owner():
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
assert 'export const PRECOMBO_TITLE = "일위대가 사전조합";' in side
assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」
assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side
assert "listTemplates(at.layer, null, at.owner)" in side
main = _text("M02_MasterTemplete_UI_Main.ts")
@@ -133,3 +133,20 @@ def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름():
# 거름은 칸 값 그대로 — 품셈 구분을 고르면 서고 줄 빠지고 새 분류를 고르면 로직 줄 빠짐
assert "(!sub.select.value || x.row.구분 === sub.select.value)" in pick
assert "(!sub.select.value || x.구분 === sub.select.value)" in pick
def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_716_3():
side = _text("M02_MasterTemplete_UI_Side.ts")
order = side[side.index("m02-side__lists") :]
marks = [
"precomboSec,",
"summary,",
"createDivider()",
"hopyo.title",
"drawing ?",
"practice ?",
]
at = [order.index(m) for m in marks]
assert at == sorted(at)
assert 'L("M02_PrecomboTitle")' in side and "buildOptionsSection" not in side
@@ -5,7 +5,7 @@
* 쓰기 없음 — 서고 목록 · 문서 읽기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림)
* 기대 수치:
* - table.wrap = "m01-master__grid-wrap" · table.table = "m01-master__grid" · table.row = "m01-master__table-row"
* - table.filters = true(찾기 칸 + 쪽 `m01-master__pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음)
* - table.filters = true(찾기 칸 + 표 바로 아래 쪽 단추 `ui-pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음)
* - table.search = 1(「값 없음」 치면 한 줄)
* - buttons.variants = ["filled", "ghost", "danger"](M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음) · buttons.redDelete = true
* - precombo.add = "로직 추가" · picker.label = "로직 고르기" · picker.kinds ⊂ {단가승격, 일위조합} · picker.logicRows = 0
@@ -100,7 +100,7 @@
row: wrap.querySelector("[data-lib-key]").className,
filters:
!!wrap.parentElement.querySelector(".m01-head__filters input[type=search]") &&
!!wrap.parentElement.querySelector(".m01-head__filters .m01-master__pager"),
wrap.nextElementSibling?.classList.contains("ui-pager") === true,
sameCell: box(td) === box(ref.querySelector("td")),
cell: box(td),
};