Files
Aislo/resources/tester/test_794_1_m01_sheet.py
eomsangdonandClaude Opus 5.5 2e7328d3a5 feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길
- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전
- 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음)
- 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로]
- 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로)
- 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음)
- 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:45:48 +09:00

235 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ruff: noqa: E501
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
REAL = store.FOLDER
KEY = "GC000211" # 합판거푸집 설치 및 해체
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
def _text(p: Path) -> str:
return p.read_text("utf-8")
def _hashes(folder: Path) -> dict[str, str]:
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
# ── 정적 ─────────────────────────────────────────────────────────────
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
for p in (SHEET, MODEL, CELL):
s = _text(p)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
sheet = _text(SHEET)
for part in (
"createCalcSheetRow",
"createFormulaCell",
'"/logic/sheet"',
'"/logic/sheet/check"',
):
assert part in sheet, part
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
lab = _text(LAB)
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
assert len(lab.splitlines()) <= 637
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
assert (
'host.dataset.view = "sheet";' in view
and '{ value: "sheet", label: tl("View_Sheet") }' in view
)
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
main = _text(ROOT / "main.py")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
# ── 서버 ─────────────────────────────────────────────────────────────
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
master = tmp_path / "master"
master.mkdir()
for path in REAL.glob("*.json"):
shutil.copy(path, master / path.name)
monkeypatch.setattr(store, "FOLDER", master)
return master
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(m01_router.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def test_이름_꼴_검사_쓰기_없음(client: TestClient, folder: Path) -> None:
before, real = _hashes(folder), _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = one["logic"]
names = _post(client, "/api/m01/logic/sheet", {"row": row})
assert names["덧줄"][0] == TOOL_1
assert names["중간"][3] == "[합판거푸집설치및해체일당].형틀목공 / 시공량"
assert (
len(names["호표"]) == len(row["호표"])
and {"글": "시공량", "갈래": "중간"} in names["이름표"]
)
ask = lambda part, at, text: _post( # noqa: E731
client, "/api/m01/logic/sheet/check", {"row": row, "갈래": part, "차례": at, "식": text}
)
good = ask("덧줄", 0, TOOL_2)
assert good["되나"] and good["식"] == "(형틀목공 * LB000007 + 보통인부 * LB000002) * 2 / 100"
assert good["글"] == TOOL_2
assert ask("덧줄", 0, "형틀목공 × [노임 없음]")["까닭"] == ["없는 이름 「[노임 없음]」"]
below = ask("중간", 0, "시공량 × 2") # 시공량 = 아래 줄
assert not below["되나"] and "시공량" in below["까닭"][0]
assert not ask("중간", 2, "(1 +")["되나"]
assert ask("호표", 0, " ")["까닭"] == ["식이 빔"]
assert not ask("호표", 0, "1" + " + 1" * 200)["되나"] # 길이 상한
assert ask("덧줄", 9, "1")["까닭"] == ["없는 자리 「덧줄 9」"]
assert ask("호표", 0, "형틀목공 × 2")["되나"]
assert row == client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] # 줄 안 바뀜
assert _hashes(folder) == before and _hashes(REAL) == real
def test_저장_길_개정_확인_다시_셈_틀린_식_422(client: TestClient, folder: Path) -> None:
real = _hashes(REAL)
one = client.get("/api/m01/logic", params={"key": KEY}).json()
row = copy.deepcopy(one["logic"])
got = line_sheet.check(row, "덧줄", 0, TOOL_2)
row["덧줄"][0]["식"] = got["식"]
body = {"files": [{"file": one["file"], "version": one["version"],
"changes": [{"op": "edit", "key": KEY, "row": row}]}]} # fmt: skip
assert client.post("/api/m01/save", json=body).status_code == 409 # 정본 = 개정 확인
_post(client, "/api/m01/save", {**body, "개정확인": True})
saved = client.get("/api/m01/logic", params={"key": KEY}).json()
assert saved["logic"]["덧줄"][0]["식"] == got["식"]
sums = _post(client, "/api/m01/calc", {"key": KEY, "inputs": INPUTS})["sums"]
assert sums["계"] == 51802 and sums["경비"] == 765 # _분석_650-H2 4 장 공구손료 2 %
bad = copy.deepcopy(saved["logic"])
bad["덧줄"][0]["식"] = "형틀목공 * 없는이름"
body = {"files": [{"file": saved["file"], "version": saved["version"],
"changes": [{"op": "edit", "key": KEY, "row": bad}]}], "개정확인": True} # fmt: skip
before = _hashes(folder)
assert client.post("/api/m01/save", json=body).status_code == 422
assert _hashes(folder) == before and _hashes(REAL) == real
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
assert client.post(url, json=body).status_code == 403
# ── 모델 ─────────────────────────────────────────────────────────────
ROW = {
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
"입력": [{"이름": "폭", "단위": "m"}],
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
} # fmt: skip
def _run() -> dict:
src = IMPORTS.sub("", _text(MODEL))
body = (
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
"\nconst now = structuredClone(R);"
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_모델_줄_갈래_값_고친_줄() -> None:
got = _run()
assert got["put"] is True and got["miss"] is False
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
by = {(x["kind"], x["이름"]): x for x in got["items"]}
assert by[("옵션", "폭")]["값"] == "3"
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
res = by[("자원", "보통인부 (인)")]
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
assert by[("단계", "노무 소계")]["식"] == "A01"
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
assert by[("합계", "계")]["값"] == 1010
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
assert got["cut"] == "글자 끝수"