Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
277 lines
12 KiB
Python
277 lines
12 KiB
Python
"""시트 로직 표 상자 — 찾기 수식 짜기(`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.blockInfo || 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);
|
|
if (job.op === "rename") {
|
|
const sl = { key: null, name: "t", resultUnit: "", edges: [],
|
|
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [
|
|
{ id: "s1", 이름: "표", 칸: {} },
|
|
{ id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] },
|
|
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
|
const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl;
|
|
const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? ""));
|
|
return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts };
|
|
}
|
|
if (job.op === "reapply") {
|
|
const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [],
|
|
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] },
|
|
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
|
|
const opts = { ref: "T1", fillBlock: true, outside: false };
|
|
const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl;
|
|
const cell = first.nodes[0].pillsIn[0].cell;
|
|
first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" };
|
|
const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl;
|
|
return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name };
|
|
}
|
|
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"]
|
|
|
|
|
|
def test_다시_짜도_선_수식은_그대로():
|
|
out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0]
|
|
assert out["kept"] == {"식": "'다른 시트'!B2"}
|
|
assert out["pills"] == ["경사"] and out["name"] == "표 · 견본"
|
|
|
|
|
|
def test_표가_비었어도_수식이_깨지지_않음():
|
|
"""내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨."""
|
|
empty = {**MINE, "rows": [{}, {}]}
|
|
r = solve(empty, {"경사": "급"}, ["할증"])
|
|
assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A"
|
|
|
|
|
|
def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수():
|
|
"""서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수."""
|
|
table = {
|
|
**R,
|
|
"conds": {"높이": "범위"},
|
|
"vals": {"증가율": "%"},
|
|
"rule": "초과·이하",
|
|
"rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}],
|
|
}
|
|
low = solve(table, {"높이": 3.5}, ["증가율"])
|
|
assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100
|
|
one = solve(table, {"높이": 5}, ["증가율"])
|
|
assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30
|
|
|
|
|
|
def test_블록_머리_차례():
|
|
table = {
|
|
**R,
|
|
"conds": {"가": "고르기", "높이": "범위"},
|
|
"vals": {"율": "%", "폭": "m"},
|
|
"rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}],
|
|
}
|
|
plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"]
|
|
assert [c["header"] for c in plan["cols"]] == [
|
|
"가",
|
|
"높이·아래",
|
|
"높이·위",
|
|
"율",
|
|
"폭·아래",
|
|
"폭·위",
|
|
]
|
|
|
|
|
|
def test_표가_늘어도_찾기_범위가_따라감():
|
|
"""마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다)."""
|
|
small = {**RATE, "rows": RATE["rows"][:2]}
|
|
extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)]
|
|
grown = {**small, "rows": [*small["rows"], *extra]}
|
|
roles = {"cond": ["높이"], "out": ["증가율"]}
|
|
plan = run(
|
|
[{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}]
|
|
)[0]
|
|
book = {
|
|
"종류": "통합문서",
|
|
"판": 1,
|
|
"열": "t",
|
|
"서식": [{}],
|
|
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
|
|
"활성": "s1",
|
|
}
|
|
values = recalc_workbooks([book])[0]["s1"]
|
|
assert values[plan["plan"]["statusCell"]]["글"] == "찾음"
|
|
assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72
|
|
|
|
|
|
def test_표_상자에_표_번호가_안_보임_이름으로_보임():
|
|
info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"}
|
|
roles = {"cond": list(info["conds"]), "out": ["석공"]}
|
|
out = run([{"op": "rename", "info": info, "roles": roles}])[0]
|
|
assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"]
|
|
assert out["ref"] == "QF000421" # 번호는 상자 ref 에만
|
|
assert "QF000421" not in " ".join(out["texts"])
|
|
assert any("13-4-1" in t and "1㎡" in t for t in out["texts"])
|
|
assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2"
|
|
assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"
|