- readLogic: 식 자리(중간 · 수량 · 요소 · 비율 · 결과)를 엔진 문법으로 읽어 ① 입력값 ② 표 조회(Table readFinds) ③ 계산(값 · 곱나눔 · 할증 · 더하기 · 조건목록 · 최소최대 · 올림 · 단계 · Cond readCond) ④ 항목(Model EasyDoc) ⑤ 결과값 - writeLogic = Model toRow · setCond · setLookupTable · setCalc 는 그 자리 글만 갈아끼운 뒤 다시 읽음 · newLogic - 못 읽는 꼴은 blocked 에 쉬운 말(표 조회 조건에 계산 · 그리고/또는 · 넘길 값에 계산) - 마스터 1,469 왕복 다른 것 0 · 블록으로 다 읽힘 1,396(95%) - 시험 resources/tester/test_m01_lb_blocks.py 14개(견본 여섯 · Node 고리로 EasyCalc 그대로 부름) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
310 lines
16 KiB
Python
310 lines
16 KiB
Python
"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA.
|
|
|
|
· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
|
|
· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴
|
|
· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.ts"
|
|
|
|
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
|
|
# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ────────────
|
|
HOOKS = r"""
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { stripTypeScriptTypes } from "node:module";
|
|
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks)(\.ts)?$/;
|
|
export async function resolve(spec, ctx, next) {
|
|
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
|
|
const u = new URL(spec + ".ts", ctx.parentURL);
|
|
if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true };
|
|
}
|
|
return next(spec, ctx);
|
|
}
|
|
export async function load(url, ctx, next) {
|
|
if (!url.endsWith(".ts")) return next(url, ctx);
|
|
let src = readFileSync(fileURLToPath(url), "utf8");
|
|
src = src.replace(/import\s+"[^"]+\.css";/g, "");
|
|
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})");
|
|
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => {
|
|
if (from.startsWith(".") && ALLOW.test(from)) return m;
|
|
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
|
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
|
});
|
|
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
|
}
|
|
"""
|
|
STUB = (
|
|
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : "
|
|
"k === 'API_BASE_URL' ? '/api' : () => undefined });\n"
|
|
)
|
|
|
|
|
|
def _run(body: str, *args: object) -> object:
|
|
"""`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
|
|
script = (
|
|
"import { register } from 'node:module';\n"
|
|
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
|
+ STUB
|
|
+ f"const B = await import({json.dumps(BLOCKS.as_uri())});\n"
|
|
"const A = JSON.parse(process.argv[1]);\n"
|
|
"const out = (x) => console.log(JSON.stringify(x));\n"
|
|
"const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--no-warnings", "--input-type=module", "-e", script, json.dumps(list(args))],
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
# ── 견본 줄 여섯(정함 10) ──────────────────────────────────────────────────
|
|
H = {"원문번호": "", "출처": "자체", "끝수": None}
|
|
GF000208 = {
|
|
**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
|
|
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
|
|
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
|
|
"덧줄": [],
|
|
} # fmt: skip
|
|
GF000219 = {
|
|
**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
|
|
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]},
|
|
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"},
|
|
{"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
|
|
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
|
|
"호표": [
|
|
{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인",
|
|
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"},
|
|
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
|
|
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"},
|
|
],
|
|
"덧줄": [],
|
|
} # fmt: skip
|
|
GF000025 = {
|
|
**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
|
|
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
|
|
"중간": [],
|
|
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
|
|
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)",
|
|
"비목": "노무비"}],
|
|
"덧줄": [],
|
|
} # fmt: skip
|
|
GF000249 = {
|
|
**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
|
|
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
|
|
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
|
|
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
|
|
"단위": "시간", "수량": "1 / 작업량"}],
|
|
"덧줄": [],
|
|
} # fmt: skip
|
|
GF000133 = {
|
|
**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
|
|
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}],
|
|
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"},
|
|
"끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)",
|
|
} # fmt: skip
|
|
GC000051 = {
|
|
**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
|
|
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
|
|
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
|
|
"덧줄": [{"이름": "공구손료 및 경장비",
|
|
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100",
|
|
"비목": "경비", "출처": "건설품셈 공통 2-9-3"}],
|
|
} # fmt: skip
|
|
SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051]
|
|
|
|
|
|
def _lb(row: dict) -> dict:
|
|
return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row)
|
|
|
|
|
|
# ── 왕복 ───────────────────────────────────────────────────────────────────
|
|
@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX])
|
|
def test_왕복_같음_다_읽힘(row: dict) -> None:
|
|
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
|
|
assert got == [row, []]
|
|
|
|
|
|
# ── 블록 내용 ───────────────────────────────────────────────────────────────
|
|
def test_메쌓기_표조회_조건목록_할증() -> None:
|
|
lb = _lb(GF000219)
|
|
assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [
|
|
("L1", "QF000422", "증가율"),
|
|
("L2", "QF000421", "석공"),
|
|
("L3", "QF000421", "보통인부"),
|
|
]
|
|
assert lb["lookups"][1]["conds"] == [
|
|
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
|
|
{"col": "돌", "from": {"k": "input", "name": "돌"}},
|
|
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
|
|
]
|
|
assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}]
|
|
mid, stone, worker = lb["calcs"]
|
|
assert (mid["name"], mid["kind"], mid["at"]) == (
|
|
"증가율",
|
|
"조건목록",
|
|
{"part": "중간", "index": 0},
|
|
)
|
|
assert mid["cond"]["blocks"][0]["shape"]["type"] == "if"
|
|
assert (stone["kind"], stone["parts"]) == (
|
|
"할증",
|
|
[{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}],
|
|
)
|
|
assert worker["parts"][0] == {"k": "lookup", "id": "L3"}
|
|
assert lb["result"] == {"kind": "금액", "unit": "원/㎡"}
|
|
assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"]
|
|
|
|
|
|
def test_떼붙임_다짐_덤프_안전통로() -> None:
|
|
turf = _lb(GF000025)
|
|
assert turf["lookups"][0]["conds"] == [
|
|
{"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}}
|
|
]
|
|
c = turf["calcs"][0]
|
|
assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"}
|
|
assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if"
|
|
|
|
tamp = _lb(GF000249)
|
|
work, qty = tamp["calcs"]
|
|
assert work["kind"] == "값" and work["parts"] == [
|
|
{"k": "logic", "key": "GF000114", "field": None}
|
|
]
|
|
assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"])
|
|
assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}]
|
|
|
|
dump = _lb(GF000133)
|
|
assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간"
|
|
assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]]
|
|
|
|
walk = _lb(GC000051)
|
|
rate = walk["calcs"][-1]
|
|
assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"]
|
|
assert rate["parts"] == [
|
|
{"k": "cost", "name": "사람줄합"},
|
|
{"k": "lookup", "id": "L2"},
|
|
{"k": "fixed", "value": 100},
|
|
]
|
|
|
|
fill = _lb(GF000208)
|
|
assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}]
|
|
|
|
|
|
# ── 고치기 ─────────────────────────────────────────────────────────────────
|
|
def _ho(row: dict) -> list[str]:
|
|
return [h["수량"] for h in row["호표"]]
|
|
|
|
|
|
def test_조건_바꾸기_빼기_표_바꾸기() -> None:
|
|
got = _run(
|
|
"let lb = B.readLogic(A[0], META);\n"
|
|
"const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n"
|
|
"const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n"
|
|
"const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n"
|
|
"const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n"
|
|
"out([a, b, c, d]);",
|
|
GF000219,
|
|
)
|
|
a, b, c, d = got
|
|
assert _ho(a) == [
|
|
"찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)",
|
|
GF000219["호표"][1]["수량"],
|
|
]
|
|
assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)"
|
|
assert (
|
|
c["중간"][0]["식"]
|
|
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))"
|
|
)
|
|
assert (
|
|
_ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
|
|
)
|
|
assert _ho(d)[0] == GF000219["호표"][0]["수량"]
|
|
|
|
|
|
def test_계산_바꾸기() -> None:
|
|
got = _run(
|
|
"const lb = B.readLogic(A[0], META);\n"
|
|
"const [mid, stone] = lb.calcs;\n"
|
|
"const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n"
|
|
"const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n"
|
|
"const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n"
|
|
"const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n"
|
|
"const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n"
|
|
"const after = B.readLogic(r1, META);\n"
|
|
"out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);",
|
|
GF000219,
|
|
)
|
|
r1, r2, r3, r4, parts, ids = got
|
|
assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)"
|
|
assert _ho(r1)[1] == GF000219["호표"][1]["수량"]
|
|
assert (
|
|
r2["중간"][0]["식"]
|
|
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))"
|
|
)
|
|
assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2"
|
|
assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}]
|
|
assert ids == ["L1", "L2", "L3"]
|
|
|
|
|
|
def test_곱나눔_더하기_괄호() -> None:
|
|
got = _run(
|
|
"const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n"
|
|
"const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n"
|
|
"const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n"
|
|
"const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 },"
|
|
" { k: 'expr', text: '작업량 - 1' }] }));\n"
|
|
"out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);",
|
|
GF000249,
|
|
)
|
|
assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]]
|
|
|
|
|
|
# ── 빈 로직 · 못 읽는 꼴 ────────────────────────────────────────────────────
|
|
def test_빈_로직() -> None:
|
|
got = _run(
|
|
"const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);"
|
|
)
|
|
row, looks, calcs, result, blocked = got
|
|
assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체"
|
|
assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, [])
|
|
|
|
|
|
def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None:
|
|
row = {
|
|
**GF000219,
|
|
"중간": [
|
|
{
|
|
"이름": "증가율",
|
|
"식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)",
|
|
}
|
|
],
|
|
}
|
|
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
|
|
assert got[0] == row
|
|
assert got[1] == [
|
|
"증가율 — 조건에 「그리고 · 또는」이 섞임",
|
|
"증가율 — 표 조회 조건에 계산이 들어 있음",
|
|
]
|
|
|
|
|
|
def test_화면_글에_금지_낱말_없음() -> None:
|
|
src = BLOCKS.read_text(encoding="utf-8")
|
|
block = src[src.index("const TEXT = {") : src.index("} as const;")]
|
|
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
|
|
assert len(korean) >= 7
|
|
for text in korean:
|
|
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"):
|
|
assert bad not in text, (bad, text)
|