Files
Aislo/resources/tester/test_m01_sl_table_build.py
T

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"