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:
@@ -0,0 +1,274 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Check.ts
|
||||
* 로직 만들기 판 — 검사(정함 7 · 설계초안 4절)
|
||||
* ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈)
|
||||
* ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복
|
||||
* ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패
|
||||
* 시험표 줄은 sessionStorage 초안만(저장 안 함)
|
||||
*
|
||||
* 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 ·
|
||||
* 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈.
|
||||
* DOM 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */
|
||||
|
||||
type Ref =
|
||||
| { k: "input"; name: string }
|
||||
| { k: "fixed"; value: number | string }
|
||||
| { k: string; [x: string]: unknown };
|
||||
|
||||
export interface CheckLookup {
|
||||
id: string;
|
||||
table: string;
|
||||
conds: { col: string; from: Ref }[];
|
||||
col: string;
|
||||
}
|
||||
|
||||
export interface CheckLogic {
|
||||
inputs: LogicInput[];
|
||||
lookups: CheckLookup[];
|
||||
}
|
||||
|
||||
type Cell = string | number | boolean | null | [number | null, number | null];
|
||||
export interface CheckTable {
|
||||
키?: string;
|
||||
범위규칙?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
|
||||
|
||||
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
|
||||
function match(
|
||||
table: CheckTable,
|
||||
row: Record<string, unknown>,
|
||||
cond: string,
|
||||
want: unknown,
|
||||
): boolean {
|
||||
if (!(cond in row)) return true; // 빈 조건 칸 = 무관
|
||||
const have = row[cond] as Cell;
|
||||
if (table.조건?.[cond] === "범위") {
|
||||
if (!Array.isArray(have) || !isNum(want)) return false;
|
||||
const [low, high] = have;
|
||||
const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · ");
|
||||
const rule = head.split(" (")[0];
|
||||
const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low);
|
||||
if (rule === "이상·이하") return above(false) && (high === null || want <= high);
|
||||
if (rule === "초과·이하" || rule === "이하")
|
||||
return above(true) && (high === null || want <= high);
|
||||
if (rule === "이상·미만") {
|
||||
const tops = table.줄
|
||||
.map((r) => r[cond])
|
||||
.filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null)
|
||||
.map((c) => c[1]);
|
||||
const closed =
|
||||
more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops);
|
||||
return above(false) && (high === null || want < high || (closed && want === high));
|
||||
}
|
||||
return false; // 모르는 범위규칙 — 엔진은 멈춤
|
||||
}
|
||||
if (isNum(have) && isNum(want)) return have === want;
|
||||
return typeof have === "string" && typeof want === "string" && have === want;
|
||||
}
|
||||
|
||||
/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */
|
||||
export function findRows(table: CheckTable, conds: Record<string, unknown>, want = ""): number[] {
|
||||
let hits = table.줄
|
||||
.map((row, i) => ({ row, i }))
|
||||
.filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v)));
|
||||
const first = (table.범위규칙 ?? "").split(" · ")[0];
|
||||
if (hits.length > 1 && (first === "이하" || first === "이상·이하")) {
|
||||
for (const c of Object.keys(conds)) {
|
||||
if (table.조건?.[c] !== "범위") continue;
|
||||
const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]);
|
||||
if (!ups.length || ups.includes(null)) continue;
|
||||
const low = Math.min(...(ups as number[]));
|
||||
hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low);
|
||||
}
|
||||
}
|
||||
if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row);
|
||||
return hits.map((h) => h.i);
|
||||
}
|
||||
|
||||
/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */
|
||||
|
||||
export interface Gap {
|
||||
table: string;
|
||||
/** 어느 표 조회에서 나왔나(화면 번호) */
|
||||
lookup: string;
|
||||
conds: Record<string, unknown>;
|
||||
kind: "없음" | "중복";
|
||||
/** 중복이면 걸린 줄 차례 */
|
||||
rows: number[];
|
||||
}
|
||||
|
||||
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
|
||||
function combos(lb: CheckLogic, lk: CheckLookup): Record<string, unknown>[] | null {
|
||||
const lists: [string, unknown[]][] = [];
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") lists.push([col, [from.value]]);
|
||||
else if (from.k === "input") {
|
||||
const spec = lb.inputs.find((x) => x.이름 === from.name);
|
||||
if (!spec?.고르기?.length) return null;
|
||||
lists.push([col, spec.고르기]);
|
||||
} else return null;
|
||||
}
|
||||
let out: Record<string, unknown>[] = [{}];
|
||||
for (const [col, values] of lists)
|
||||
out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v })));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
|
||||
export function gaps(lb: CheckLogic, tables: Record<string, unknown>): Gap[] {
|
||||
const out: Gap[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const sets = table?.줄 ? combos(lb, lk) : null;
|
||||
if (!table || !sets) continue;
|
||||
for (const conds of sets) {
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
if (rows.length === 1) continue;
|
||||
const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`;
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push({
|
||||
table: lk.table,
|
||||
lookup: lk.id,
|
||||
conds,
|
||||
kind: rows.length ? "중복" : "없음",
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
|
||||
export function gapSkipped(lb: CheckLogic, tables: Record<string, unknown>): string[] {
|
||||
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
|
||||
}
|
||||
|
||||
/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */
|
||||
|
||||
/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */
|
||||
export type LbHit = number | null | "없음" | "중복";
|
||||
|
||||
export function lookupHits(
|
||||
lb: CheckLogic,
|
||||
tables: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, LbHit> {
|
||||
const out: Record<string, LbHit> = {};
|
||||
for (const lk of lb.lookups) {
|
||||
const table = tables[lk.table] as CheckTable | undefined;
|
||||
const conds: Record<string, unknown> = {};
|
||||
let known = !!table?.줄;
|
||||
for (const { col, from } of lk.conds) {
|
||||
if (from.k === "fixed") conds[col] = from.value;
|
||||
else if (from.k === "input" && values[from.name as string] !== undefined)
|
||||
conds[col] = values[from.name as string];
|
||||
else known = false;
|
||||
}
|
||||
if (!known || !table) {
|
||||
out[lk.id] = null;
|
||||
continue;
|
||||
}
|
||||
const rows = findRows(table, conds, lk.col);
|
||||
out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */
|
||||
|
||||
export interface ExpectRow {
|
||||
/** 예시값 */
|
||||
inputs: Record<string, unknown>;
|
||||
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
|
||||
expect: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ExpectResult {
|
||||
ok: boolean;
|
||||
got: Record<string, number>;
|
||||
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
|
||||
why: string | null;
|
||||
}
|
||||
|
||||
/** 계산 답 → 볼 값 모음 */
|
||||
export function pickValues(ans: CalcAnswer): Record<string, number> {
|
||||
const got: Record<string, number> = {};
|
||||
if (!ans.ok) return got;
|
||||
if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"];
|
||||
if (isNum(ans.result)) got["결과"] = ans.result;
|
||||
for (const line of ans.lines ?? []) {
|
||||
if (isNum(line.수량)) got[line.이름] = line.수량;
|
||||
if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */
|
||||
export function sameAsExpect(got: number | undefined, expect: number): boolean {
|
||||
if (!isNum(got)) return false;
|
||||
const places = (String(expect).split(".")[1] ?? "").length;
|
||||
return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9;
|
||||
}
|
||||
|
||||
type CalcFn = (
|
||||
row: LogicRow,
|
||||
inputs: Record<string, unknown>,
|
||||
tables: Record<string, unknown>,
|
||||
) => Promise<CalcAnswer>;
|
||||
|
||||
/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */
|
||||
export async function runExpect(
|
||||
row: LogicRow,
|
||||
tables: Record<string, unknown>,
|
||||
rows: ExpectRow[],
|
||||
calc: CalcFn,
|
||||
): Promise<ExpectResult[]> {
|
||||
const out: ExpectResult[] = [];
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const ans = await calc(row, r.inputs, tables);
|
||||
const got = pickValues(ans);
|
||||
const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v));
|
||||
out.push({ ok, got, why: ans.ok ? null : ans.reason });
|
||||
} catch (e) {
|
||||
out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */
|
||||
|
||||
const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`;
|
||||
|
||||
export function loadExpectRows(logic: string): ExpectRow[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(logic));
|
||||
const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : [];
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveExpectRows(logic: string, rows: ExpectRow[]): void {
|
||||
try {
|
||||
if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows));
|
||||
else sessionStorage.removeItem(draftKey(logic));
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user