feat(M01): 로직 만들기 판 검사 Check 초안 — 빈틈 · 중복 · 걸린 줄 · 기대값 시험표(PLAN 35-2)

- 줄 찾기는 엔진 _match · find 규칙 그대로(범위규칙 넷 · 빈 칸 무관 · 위 끝 가장 작은 줄 · 결과 칸 있는 줄)
- 시험 test_m01_lb_check.py 7 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
2026-10-01 11:43:52 +09:00
co-authored by Claude Opus 5.5
parent 32b0b95479
commit 4f12eb3611
2 changed files with 564 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
"""로직 만들기 판 — 검사(`M01_LB_Check.ts`)의 빈틈 · 중복 · 걸린 줄 · 기대값 맞춤.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 서버는 빈 흉내 · 순수 함수만).
견본 표 · 로직은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Check.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 stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
Promise.all(
jobs.map(async (job) => {
if (job.op === "gaps") return m.gaps(job.lb, job.tables);
if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables);
if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values);
if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? "");
if (job.op === "same") return m.sameAsExpect(job.got, job.expect);
if (job.op === "expect") {
const answers = job.answers;
let i = 0;
return m.runExpect({}, {}, job.rows, async () => answers[i++]);
}
return null;
}),
).then((out) => 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(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관
STONE = {
"키": "T1",
"조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
"값칸": {"석공": "인"},
"줄": [
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15},
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13},
{"뒷길이": 25, "돌": "호박돌", "석공": 0.10},
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22},
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복
],
}
# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함)
HEIGHT = {
"키": "T2",
"범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함",
"조건": {"높이": "범위"},
"값칸": {"증가율": "%"},
"줄": [
{"높이": [None, 2], "증가율": 0},
{"높이": [2, 4], "증가율": 30},
{"높이": [4, 6], "증가율": 40},
],
}
# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄)
UPTO = {
"키": "T3",
"범위규칙": "이하",
"조건": {"거리": "범위"},
"값칸": {"값": ""},
"줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}],
}
TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO}
def lk(id_, table, conds, col):
return {"id": id_, "table": table, "conds": conds, "col": col}
LOGIC = {
"inputs": [
{"이름": "뒷길이", "고르기": [25, 30]},
{"이름": "돌", "고르기": ["깬잡석", "호박돌"]},
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
{"이름": "높이", "고르기": [1, 3, 6, 7]},
{"이름": "길이"}, # 숫자 입력 — 선택 목록 없음
],
"lookups": [
lk(
"a",
"T1",
[
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "input", "name": "돌"}},
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
],
"석공",
),
lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"),
lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"),
],
}
def by(gaps, lookup):
return sorted(
(
(g["kind"], json.dumps(g["conds"], ensure_ascii=False))
for g in gaps
if g["lookup"] == lookup
)
)
def test_빈틈_중복_무관_빈칸():
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
got = by(gaps, "a")
# 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합
# 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님)
# 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜
# 중복 — 30 · 깬잡석 · 골쌓기(두 줄)
assert got == sorted(
[
(
"없음",
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False),
),
(
"없음",
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False),
),
(
"없음",
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False),
),
(
"중복",
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False),
),
]
)
dup = next(g for g in gaps if g["kind"] == "중복")
assert dup["rows"] == [3, 4]
def test_범위_줄_이상미만_마지막칸_이하포함():
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
# 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음
assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))]
out = run(
[
{"op": "find", "table": HEIGHT, "conds": {"높이": 2}},
{"op": "find", "table": HEIGHT, "conds": {"높이": 4}},
{"op": "find", "table": HEIGHT, "conds": {"높이": 6}},
]
)
assert out == [[1], [2], [2]]
def test_범위_이하_위끝_가장_작은_줄():
out = run(
[
{"op": "find", "table": UPTO, "conds": {"거리": 5}},
{"op": "find", "table": UPTO, "conds": {"거리": 15}},
{"op": "find", "table": UPTO, "conds": {"거리": 25}},
]
)
assert out == [[0], [1], []]
def test_셀_수_없는_조회는_건너뜀():
gaps, skipped = run(
[
{"op": "gaps", "lb": LOGIC, "tables": TABLES},
{"op": "skipped", "lb": LOGIC, "tables": TABLES},
]
)
assert by(gaps, "c") == []
assert skipped == ["c"]
def test_예시값으로_걸린_줄():
[hits] = run(
[
{
"op": "hits",
"lb": LOGIC,
"tables": TABLES,
"values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15},
}
]
)
assert hits == {"a": "중복", "b": 1, "c": 1}
[hits] = run(
[
{
"op": "hits",
"lb": LOGIC,
"tables": TABLES,
"values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"},
}
]
)
assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름
def test_고정값_조건():
lb = {
"inputs": [],
"lookups": [
lk(
"f",
"T1",
[
{"col": "돌", "from": {"k": "fixed", "value": "호박돌"}},
{"col": "뒷길이", "from": {"k": "fixed", "value": 25}},
],
"석공",
),
lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"),
],
}
gaps, hits = run(
[
{"op": "gaps", "lb": lb, "tables": TABLES},
{"op": "hits", "lb": lb, "tables": TABLES, "values": {}},
]
)
assert gaps == []
assert hits == {"f": 2, "g": None}
def test_기대값_시험표():
answers = [
{
"ok": True,
"lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}],
"sums": {"계": 52437.06},
"middle": {},
},
{
"ok": True,
"lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}],
"sums": {"계": 50000},
"middle": {},
},
{"ok": False, "reason": "표에 줄이 없음"},
]
rows = [
{"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}},
{"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}},
{"inputs": {"뒷길이": 99}, "expect": {"계": 1}},
]
[res] = run([{"op": "expect", "rows": rows, "answers": answers}])
assert [r["ok"] for r in res] == [True, False, False]
assert res[0]["got"]["석공 금액"] == 52437.06
assert res[2]["why"] == "표에 줄이 없음"
same = run(
[
{"op": "same", "got": 52437.4, "expect": 52437},
{"op": "same", "got": 52437.6, "expect": 52437},
{"op": "same", "got": 0.264, "expect": 0.26},
]
)
assert same == [True, False, True]