Files
Aislo/resources/tester/test_m01_lb_check.py
T
eomsangdonandClaude Opus 5.5 f62689dff1 feat(M01): 로직 만들기 판 왼쪽 원문 Source · Check 를 LB 타입에 맞춤(PLAN 35-2)
- renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처
- Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번
- 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 11:47:53 +09:00

383 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""로직 만들기 판 — 검사(`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]
SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts"
RUNNER_UI = 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"));
const out = jobs.map((job) => {
if (job.op === "parse") return m.parseCell(job.text, job.before);
if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]);
if (job.op === "role") return m.roleOf(job.lookups, job.col);
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run_ui(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_원문_칸_고친_글_읽기():
out = run_ui(
[
{"op": "parse", "text": "0.3", "before": 0.26},
{"op": "parse", "text": "1,200", "before": 1000},
{"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음
{"op": "parse", "text": "3~4", "before": [2, 3]},
{"op": "parse", "text": "~5", "before": [None, 4]},
{"op": "parse", "text": "7~", "before": [6, None]},
{"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음
{"op": "parse", "text": "깬돌", "before": "깬잡석"},
{"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로
{"op": "parse", "text": "", "before": 1},
]
)
assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None]
def test_계산에_쓰인_주와_칸_역할():
[notes] = run_ui(
[
{
"op": "notes",
"row": {
"중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}],
"비고": "주① 참고",
},
}
]
)
assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]]
lookups = [
{
"id": "a",
"table": "T1",
"col": "석공",
"conds": [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}},
],
}
]
out = run_ui(
[
{"op": "role", "lookups": lookups, "col": "석공"},
{"op": "role", "lookups": lookups, "col": "뒷길이"},
{"op": "role", "lookups": lookups, "col": "돌"},
{"op": "role", "lookups": lookups, "col": "쌓기"},
]
)
assert out == ["결과", "조건", "고정", None]