feat(m01-sl): 시트 로직 표 상자 손잡이 Table 초안 — 품셈 표 가져오기 · 내 표 · 찾기 수식 짜기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조.
|
||||
견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "plan") {
|
||||
const plan = m.planLookup(job.info, job.roles);
|
||||
const cells = { ...m.blockCells(job.info), ...plan.cells };
|
||||
for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v };
|
||||
return { cells, plan };
|
||||
}
|
||||
if (job.op === "paste") return m.parsePaste(job.text);
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []}
|
||||
EXACT = {
|
||||
**R,
|
||||
"conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
|
||||
"vals": {"석공": "인", "보통": "인"},
|
||||
"rows": [
|
||||
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12},
|
||||
{"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5},
|
||||
],
|
||||
}
|
||||
RATE = {
|
||||
**R,
|
||||
"conds": {"높이": "범위"},
|
||||
"vals": {"증가율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"높이": [3, 4], "증가율": 30},
|
||||
{"높이": [4, 5.5], "증가율": 40},
|
||||
{"높이": [5.5, None], "증가율": 60},
|
||||
],
|
||||
}
|
||||
MIXED = {
|
||||
**R,
|
||||
"conds": {"구분": "고르기", "높이": "범위"},
|
||||
"vals": {"율": "%"},
|
||||
"rule": "초과·이하",
|
||||
"rows": [
|
||||
{"구분": "가", "높이": [0, 2], "율": 1},
|
||||
{"구분": "가", "높이": [2, 4], "율": 2},
|
||||
{"구분": "나", "높이": [0, 3], "율": 5},
|
||||
],
|
||||
}
|
||||
MINE = {
|
||||
**R,
|
||||
"conds": {"경사": "고르기"},
|
||||
"vals": {"할증": "%"},
|
||||
"rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}],
|
||||
}
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict:
|
||||
"""견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}."""
|
||||
roles = {"cond": cond or list(info["conds"]), "out": out}
|
||||
plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0]
|
||||
book = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "t",
|
||||
"서식": [{}],
|
||||
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
||||
"활성": "s1",
|
||||
}
|
||||
values = recalc_workbooks([book])[0]["s1"]
|
||||
p = plan["plan"]
|
||||
|
||||
def got(a1: str):
|
||||
v = values.get(a1) or {}
|
||||
return v.get("수", v.get("글", v.get("오류")))
|
||||
|
||||
res = {c: got(a) for c, a in p["outCells"].items()}
|
||||
return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res}
|
||||
|
||||
|
||||
def test_같음_조건_셋은_열쇠로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"])
|
||||
assert r["상태"] == "찾음" and r["줄"] == "3"
|
||||
assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21
|
||||
|
||||
|
||||
def test_빈칸은_무관_줄로_찾음():
|
||||
r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert r["상태"] == "찾음" and float(r["석공"]) == 0.1
|
||||
|
||||
|
||||
def test_맞는_줄_없음과_둘_이상():
|
||||
none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"])
|
||||
assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A"
|
||||
dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"])
|
||||
assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A"
|
||||
|
||||
|
||||
def test_범위_조건은_아래_위_두_칸으로_초과_이하():
|
||||
assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하)
|
||||
assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40
|
||||
assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음
|
||||
assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님
|
||||
assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_같음_조건과_범위_조건이_섞임():
|
||||
assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2
|
||||
assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5
|
||||
assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음"
|
||||
|
||||
|
||||
def test_내_표_경사에서_할증_두_줄():
|
||||
assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10
|
||||
assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0
|
||||
|
||||
|
||||
def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜():
|
||||
r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"])
|
||||
assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋
|
||||
|
||||
|
||||
def test_붙여넣기_읽기():
|
||||
text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10"
|
||||
out = run([{"op": "paste", "text": text}])[0]
|
||||
info = out["info"]
|
||||
assert out["error"] is None
|
||||
assert info["conds"] == {"경사": "고르기", "범위": "범위"}
|
||||
assert info["vals"] == {"할증": ""}
|
||||
assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10}
|
||||
assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"]
|
||||
Reference in New Issue
Block a user