feat(M02-흐름도): 식 보기 FormulaView · 엑셀 조건 만들기 spreadsheet_condition(우클릭 · conditions 줄 때만) + 시험

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 23:30:11 +09:00
co-authored by Claude Sonnet 5.5
parent 4a54497795
commit e799fbf345
6 changed files with 1020 additions and 0 deletions
@@ -0,0 +1,180 @@
"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로.
견본 함수 상자는 이 시험 안에 둠.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts"
TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, viewFile, textFile] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
const cache = {};
function load(f) {
if (cache[f]) return cache[f].exports;
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = (cache[f] = { exports: {} });
const req = (id) => {
if (id.startsWith(".") || id.startsWith("@ui/sheet/")) {
const p = id.startsWith("@ui/sheet/")
? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length))
: path.resolve(path.dirname(f), id);
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
}
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, req);
return box.exports;
}
const v = load(viewFile);
const t = load(textFile);
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "lines") {
const values = v.cellValuesOf(job.box, job.arrived);
return { values, lines: v.formulaLines(job.box, values) };
}
if (job.op === "toFormula") return t.draftToFormula(job.draft);
if (job.op === "toDraft") return t.formulaToDraft(job.formula);
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict:
return {
"id": "f1",
"kind": "function",
"name": "석공 품",
"color": "",
"unit": "인",
"x": 0,
"y": 0,
"func": {
"sheet": {"id": "s1", "이름": "석공 품", "칸": cells},
"inputs": inputs,
"variables": variables,
"output": {"cell": out_cell, "name": out_name, "unit": "인"},
},
}
def var(name: str, cell: str, example) -> dict:
return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell}
# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100)
PLAIN = func_box(
{
"A1": {"값": "석공량"},
"B3": {"식": "B1*(1+B2/100)"},
},
[{"port": "석공량", "cell": "B1"}],
[var("높이", "B2", 4)],
"B3",
"석공 인",
)
def test_이름_식과_값_식():
(got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}])
assert got["values"]["B3"] == pytest.approx(0.156)
(line,) = got["lines"]
assert line["name"] == "석공 인"
assert line["named"] == "석공량 × (1 + 높이 ÷ 100)"
assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156"
assert "branches" not in line
def test_중간_칸이_먼저_결과_칸이_마지막():
box = func_box(
{"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}},
[{"port": "석공량", "cell": "B1"}],
[var("높이", "B2", 4)],
"B4",
"합",
)
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}])
assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로
assert got["lines"][1]["named"] == "B3 + 1"
assert got["lines"][1]["valued"] == "8 + 1 = 9"
@pytest.mark.parametrize(
("ground", "now"),
[("토사", [True, False]), ("암", [False, True])],
)
def test_IF_줄과_지금_갈래(ground, now):
box = func_box(
{"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}},
[{"port": "석공량", "cell": "B1"}],
[{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}],
"B3",
"결과",
)
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}])
(line,) = got["lines"]
assert [b["now"] for b in line["branches"]] == now
assert line["branches"][0]["when"] == '지반 = "토사"'
assert line["branches"][0]["then"] == "석공량 × 2"
assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]}
assert line["valued"].endswith("= 20" if ground == "토사" else "= 30")
def test_조건_만들기_식과_다시_줄():
draft = {
"branches": [
{
"left": "B1",
"op": "=",
"right": {"kind": "value", "value": "토사"},
"then": {"kind": "cell", "cell": "C1"},
},
{
"left": "B1",
"op": "≥",
"right": {"kind": "value", "value": 3},
"then": {"kind": "value", "value": 0.5},
},
],
"otherwise": {"kind": "value", "value": 0},
}
(formula,) = run([{"op": "toFormula", "draft": draft}])
assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))'
(back,) = run([{"op": "toDraft", "formula": formula}])
assert back == draft
@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"])
def test_못_바꾸는_식은_null(formula):
(back,) = run([{"op": "toDraft", "formula": formula}])
assert back is None