feat(792-1): 단가산출 식 칸 TS 캐시 모델 · 지킴 시험 · 같은 내용 지문에 식 칸

- 새 logic/M02_Sangeun_Formula_Model.ts — setFormula · addVar · editVar · dropVar(서버 clean 이 받는 꼴)
- Const_Model restoreM01 · coverKinds 에 식덮개(서버 restore · kinds 거울)
- M02_Library_Same SK 지문에 상수 · 표값 · 식 덮개 · 변수줄(식만 다른 SK 를 같은 것 409 로 막던 흠 · 빈 칸 = 옛 지문 그대로)
- Formula 검사 길 0 나눔 알림이 못 읽는 식에서 멈추던 흠
- 새 test_792_1_sangeun_formula.py 13 — 할증율 변수 줄 51,501 · 합판거푸집 공구손료 식 51,802 · 수량식 · 이름 꼴 왕복 · 검사 까닭 · 상한 · 순환 · 옛 SK · 저장 400 · B09 굳힘 · TS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:29:40 +09:00
co-authored by Claude Opus 5.5
parent 61ebb81971
commit bb39bacd26
5 changed files with 453 additions and 4 deletions
+2
View File
@@ -131,6 +131,8 @@ class Same:
"덮개": norm(_bare(doc.get("덮개"))),
"더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]),
"뺀줄": sorted(map(str, doc.get("뺀줄") or [])),
# 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로
**{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")},
}
return {k: v for k, v in body.items() if v not in EMPTY}
+5 -1
View File
@@ -603,8 +603,12 @@ def _risks(text: str, base: dict, doc: dict) -> list[str]:
"""0 나눔 위험 — 나누는 쪽 이름이 입력값 0 · 고르기에 0."""
mf, out = _eng(), []
given = doc.get("입력값") or {}
try:
tree = mf.parse(text)
except (mf.FormulaError, RecursionError):
return out
picks = {str(x.get("이름")): x.get("고르기") or [] for x in base.get("입력") or []}
for node in mf.walk(mf.parse(text)):
for node in mf.walk(tree):
if node[0] == "bin" and node[1] == "/":
for n in (x[1] for x in mf.walk(node[3]) if x[0] == "name"):
zero = str(given.get(n)) in ("0", "0.0") or any(
@@ -31,7 +31,7 @@ export interface ConstDoc {
[k: string]: unknown;
}
export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌";
export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌";
const ADD = /^add:#(\d+)(\/.*)?$/;
const PARTS = ["labor", "material", "expense"];
@@ -120,6 +120,7 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
let out: ConstDoc = { ...doc };
delete out.상수덮개;
delete out.표값덮개;
delete out.식덮개; // 식 칸(792-1)
delete out.까닭;
const why = { ...(doc.까닭 ?? {}) };
if (key == null) {
@@ -149,8 +150,9 @@ export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
} else {
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
for (const name of ["상수덮개", "표값덮개"] as const) {
const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key));
for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) {
const all = (doc[name] ?? {}) as Record<string, unknown>;
const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key));
out = put(out, name, got);
}
}
@@ -187,6 +189,7 @@ export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
if (PARTS.some((k) => k in cover)) out.push("단가");
if (doc.상수덮개?.[spot]) out.push("상수");
if (doc.표값덮개?.[spot]) out.push("표값");
if ((doc.식덮개 as Record<string, string> | undefined)?.[spot]) out.push("식");
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
return out;
}
@@ -0,0 +1,116 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Formula_Model.ts
* 단가산출(SK) 사용자 식 칸 캐시 고치기(PLAN 792-1 · 요구서_652 · _분석_650-H3 E4) —
* 서버 `M02_MasterTemplete/M02_Sangeun_Formula.py`(꼴 검사 · 번역 · 셈)와 같은 꼴 · 서버 짝 함수 없음
* · 시험 `resources/tester/test_792_1_sangeun_formula.py`(여기 결과를 서버 `clean` 이 받음).
* 변수줄 [{이름, 식, 표시?, 자리}] 엔진 중간 줄 · 자리 = 그 뒤 중간 `mid:이름`("" = 맨 위)
* 식덮개 {자리: 식} 중간 `mid:이름` · 호표 · 덧줄 cid
* 더한줄[k].수량식 율 줄 · 로직 줄 빼고
* 식 글 = 검사 길(`formula-check`) 답 `식`(엔진) 을 넣음 · 이름 꼴 글을 넣어도 서버가 번역.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { ConstDoc } from "./M02_Sangeun_Const_Model";
export interface VarLine {
이름: string;
식: string;
표시?: number;
자리: string;
}
export interface FormulaDoc extends ConstDoc {
변수줄?: VarLine[];
식덮개?: Record<string, string>;
}
const ADD_ONE = /^add:#(\d+)$/;
/** 빈 묶음 · 목록 칸은 지움(서버처럼 없으면 안 둠). */
function putField<T extends FormulaDoc>(doc: T, name: string, value: unknown): T {
const out: FormulaDoc = { ...doc };
const empty =
value == null ||
(Array.isArray(value)
? value.length === 0
: typeof value === "object" && Object.keys(value as object).length === 0);
if (empty) delete out[name];
else out[name] = value;
return out as T;
}
const isVar = (doc: FormulaDoc, spot: string): boolean =>
spot.startsWith("mid:") && (doc.변수줄 ?? []).some((v) => v.이름 === spot.slice(4));
/** 자리 식 — 변수 줄 = 그 식 · `add:#k` = 수량식 · 그 밖 = 식덮개 · null · "" = 지움(M01 식 · 수). */
export function setFormula<T extends FormulaDoc>(doc: T, spot: string, text: string | null): T {
const t = (text ?? "").trim();
if (isVar(doc, spot)) return editVar(doc, spot.slice(4), { 식: t });
const hit = ADD_ONE.exec(spot);
if (hit) {
const k = Number(hit[1]);
const adds = (doc.더한줄 ?? []).map((x, i) => {
if (i !== k) return x;
const one: Record<string, unknown> = { ...x };
if (t) one.수량식 = t;
else delete one.수량식;
return one;
});
return { ...doc, 더한줄: adds };
}
const covers = { ...(doc.식덮개 ?? {}) };
if (t) covers[spot] = t;
else delete covers[spot];
return putField(doc, "식덮개", covers);
}
/** 변수 줄 넣기 — `at` 차례(없으면 끝) · 같은 이름이 있으면 그대로. */
export function addVar<T extends FormulaDoc>(doc: T, line: VarLine, at?: number): T {
const lines = [...(doc.변수줄 ?? [])];
if (lines.some((v) => v.이름 === line.이름)) return doc;
lines.splice(at ?? lines.length, 0, { ...line });
return putField(doc, "변수줄", lines);
}
/** 변수 줄 고침 — 이름 바꾸면 까닭 · 글줄 · 다른 변수 자리 열쇠 `mid:이름` 도 따라감(식 속 이름은
* 그대로 — 검사 길이 짚음). */
export function editVar<T extends FormulaDoc>(doc: T, name: string, patch: Partial<VarLine>): T {
const to = patch.이름 ?? name;
const lines = (doc.변수줄 ?? []).map((v) => {
if (v.이름 === name) {
const one: VarLine = { ...v, ...patch };
if (patch.표시 == null && "표시" in patch) delete one.표시;
return one;
}
return v.자리 === `mid:${name}` ? { ...v, 자리: `mid:${to}` } : v;
});
const out = putField(doc, "변수줄", lines);
return to === name ? out : moveKey(out, `mid:${name}`, `mid:${to}`);
}
/** 변수 줄 빼기 — 그 뒤에 붙은 변수는 그 자리를 이어받음 · 까닭 지움 · 글줄은 맨 위로. */
export function dropVar<T extends FormulaDoc>(doc: T, name: string): T {
const gone = (doc.변수줄 ?? []).find((v) => v.이름 === name);
if (!gone) return doc;
const lines = (doc.변수줄 ?? [])
.filter((v) => v.이름 !== name)
.map((v) => (v.자리 === `mid:${name}` ? { ...v, 자리: gone.자리 } : v));
return moveKey(putField(doc, "변수줄", lines), `mid:${name}`, null);
}
/** 까닭 · 글줄 열쇠 옮김(null = 까닭 지움 · 글줄 맨 위). */
function moveKey<T extends FormulaDoc>(doc: T, from: string, to: string | null): T {
let out: T = doc;
if (doc.까닭 && from in doc.까닭) {
const why = { ...doc.까닭 };
const text = why[from];
delete why[from];
if (to) why[to] = text;
out = putField(out, "까닭", why);
}
if (doc.글줄) {
out = { ...out, 글줄: doc.글줄.map((t) => (t.자리 === from ? { ...t, 자리: to ?? "" } : t)) };
}
return out;
}
@@ -0,0 +1,324 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""792-1 단가산출(SK) 사용자 식 칸 — 변수 줄 · 식 덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사.
- 금액 = 서버 셈 = _분석_650-H2 4 장 견본(인력운반 「할증율 20」 변수 줄 + 보통인부 품 식
× (1 + 할증율 / 100) = 51,501) · 합판거푸집 줄 식 고침 · 공구손료 식 2 % = 51,802(790-2 같은 값)
- 이름 꼴(`×` · `[노임 형틀목공]` · `[별칭]`) ↔ 엔진 식 왕복 = M01 로직 견본 식 전부 같음
- 검사 까닭 — 없는 이름 · 괄호 · 순환 · 0 나눔 · 길이 · 괄호 깊이 · 변수 줄 수 · 자리 겹침
- 옛 SK(식 칸 없음) = 꼴 · 금액 그대로 · 저장 = 엔진 식 · 틀린 식 저장 400 · 셈 답 덧칸
- B09 사본 굳힘(`GX9#####`) 줄 = 식 칸 그대로 · 다시 셈 같은 금액
- TS `Formula_Model` · `Const_Model`(식덮개) 결과 = 서버가 받음 · [M01 원래로] 거울
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Sangeun as sk
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Const as const
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
FORM, CARRY = "GC000211", "GC001140" # 합판거푸집 · 인력운반(지게)
MAN = "labor:LB000002#0" # 인력운반 보통인부
TOOL = "pct:공구손료 및 경장비#0"
F211 = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님"}
F211 |= {"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0}
PLUS = "무게 / 운반량 / 일운반회수 × (1 + 할증율 / 100)"
def _whole():
return m01.loaded()[1]
def _carry():
base = json.loads(json.dumps(_whole().logic(CARRY)["시험입력"], default=str))
return {**base, "재료종별": "암석", "재료형상": "화강암", "운반회수": 0}
def _doc(code, given, **more):
return {
"키": "SK-000001",
"이름": "견본",
"단위": "식",
"바탕": {"로직": code},
"입력값": given,
}
def _sk_doc(code, given, **more):
return {**_doc(code, given), **more}
def _run(doc):
whole = _whole()
return calc.run(sd.clean(doc, None, whole), whole)
def _sum(doc):
return float(_run(doc)["기준"]["계"])
def _plus(**more):
var = [{"이름": "할증율", "식": "20", "자리": ""}]
return _sk_doc(CARRY, _carry(), **({"변수줄": var, "식덮개": {MAN: PLUS}} | more))
# ── 견본 금액 ─────────────────────────────────────────────────────────
def test_변수_줄_할증율_견본():
assert _sum(_doc(CARRY, _carry())) == 42917
assert _sum(_plus()) == 51501 # _분석_650-H2 4 장
var = [{"이름": "할증율", "식": "만약(운반거리 > 5, 20, 0)", "표시": 0, "자리": "mid:무게"}]
assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: PLUS})) == 51501
eng = PLUS.replace("×", "*")
assert _sum(_sk_doc(CARRY, _carry(), 변수줄=var, 식덮개={MAN: eng})) == 51501 # 엔진 꼴도
def test_합판거푸집_줄_식_고침():
base = _sum(_doc(FORM, F211))
assert base == 51419
tool = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 2 / 100"
assert _sum(_sk_doc(FORM, F211, 식덮개={TOOL: tool})) == 51802 # = 790-2 공구손료 2 %
more = "[합판거푸집설치및해체일당].형틀목공 × 1.1 / 시공량"
got = _run(_sk_doc(FORM, F211, 식덮개={"mid:형틀목공": more}))
line = next(x for x in got["줄"] if x["cid"] == "labor:LB000007#0")
assert float(line["수량"]) == pytest.approx(5 * 1.1 / 45)
assert got["기준"]["계"] > base
def test_더한줄_수량식():
add = {
"종류": "인력",
"요소": "LB000002",
"수량": 1,
"수량식": "무게 / 운반량 / 일운반회수 / 2",
}
got = _run(_sk_doc(CARRY, _carry(), 더한줄=[add]))
one, mine = (next(x for x in got["줄"] if x["cid"] == c) for c in (MAN, "add:#0"))
assert float(mine["수량"]) == pytest.approx(float(one["수량"]) / 2)
for bad in ({"수량식": "1", "종류": "로직", "로직": FORM, "수량": 1},):
with pytest.raises(sd.DocError, match="수만"):
sd.clean(_sk_doc(FORM, F211, 더한줄=[bad]), None, _whole())
# ── 이름 꼴 ↔ 엔진 식 ────────────────────────────────────────────────
def test_이름_꼴_양방향():
whole = _whole()
row = whole.logic(FORM)
tool = row["덧줄"][0]["식"]
named = fx.to_name(tool, row)
assert named == "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
mid = fx.to_name(row["중간"][2]["식"], row)
assert mid.startswith("[합판거푸집설치및해체일당].시공량 × (1 - ")
assert fx.to_engine("[LB000007] ÷ 2", row) == ("LB000007 / 2", [])
assert fx.to_engine("[노임 형틀목공 LB000007] × 2", row)[0] == "LB000007 * 2"
assert fx.to_engine("합판거푸집구멍땜.시멘트", row)[0] == "찾기(QC000430).시멘트" # 맨 별칭
assert fx.to_engine("'[가]' × 1", row)[0] == "'[가]' * 1" # 따옴표 안 그대로
assert fx.to_engine("[없는것] × 1", row)[1] == ["없는 이름 「[없는것]」"]
assert fx.to_engine("[노임 × 1", row)[1] == ["대괄호 짝 안 맞음"]
for code in (FORM, CARRY, "GC000374", "GC000360"): # 띄어 쓴 자재 이름 · 긴 덧줄
one = whole.logic(code)
for part, f in (("중간", "식"), ("호표", "수량"), ("덧줄", "식")):
for x in one.get(part) or []:
assert fx.to_engine(fx.to_name(str(x[f]), one), one) == (str(x[f]), [])
# ── 검사 ─────────────────────────────────────────────────────────────
def _check(spot, text, doc=None):
return fx.check_doc(doc or _plus(), spot, text)
def test_검사_까닭():
ok = _check(MAN, PLUS)
assert ok["되나"] and ok["식"] == PLUS.replace("×", "*") and ok["글"] == PLUS
assert _check(MAN, "무게 / 없는것")["까닭"] == ["모르는 이름 「없는것」"]
assert "「)」 없음" in _check(MAN, "(무게 / 2")["까닭"][0]
assert _check("mid:할증율", "할증율 + 1")["까닭"] == ["순환 — 「할증율」 이 자기 자신을 씀"]
two = _plus(
변수줄=[{"이름": "가", "식": "1", "자리": ""}, {"이름": "나", "식": "가", "자리": ""}]
)
assert _check("mid:가", "나 + 1", two)["까닭"] == ["순환 — 가 → 나 → 가"]
assert _check("mid:할증율", "운반무게 / 0")["까닭"] == ["0 으로 나눔"]
assert _check(MAN, "무게 / 운반회수")["알림"] == ["「운반회수」 가 0 이면 0 나눔"]
assert _check(MAN, "1" + " + 1" * 80)["까닭"] == [f"식이 {fx.LONG} 자를 넘음"]
assert _check(MAN, "(" * 13 + "1" + ")" * 13)["까닭"] == [f"괄호가 {fx.DEEP} 겹을 넘음"]
assert _check("add:#3", "1")["까닭"] == ["없는 자리 「add:#3」"]
assert _check(MAN, " ")["까닭"] == ["식이 빔"]
def test_꼴_상한_겹침():
whole = _whole()
many = [{"이름": f"v{i}", "식": "1", "자리": ""} for i in range(fx.MANY + 1)]
for bad, why in (
({"변수줄": many}, "까지 목록"),
({"변수줄": [{"이름": "1가", "식": "1"}]}, "이름 꼴"),
({"변수줄": [{"이름": "가", "식": "1"}, {"이름": "가", "식": "2"}]}, "같은 이름"),
({"변수줄": [{"이름": "가", "식": "1", "표시": 7}]}, "0~6"),
({"식덮개": {"mid:시공량": "1"}, "상수덮개": {"mid:시공량": {"0": 1}}}, "같이 못 둠"),
({"식덮개": {"labor:LB000007#0": "1"}, "덮개": {"labor:LB000007#0": {"qty": 1}}}, "qty"),
({"식덮개": {"엉뚱": "1"}}, "자리 꼴"),
({"식덮개": {"mid:시공량": "(" * 13}}, "괄호"),
):
with pytest.raises(sd.DocError, match=why):
sd.clean(_sk_doc(FORM, F211, **bad), None, whole)
def test_틀린_식은_그_자리만_M01_식():
"""셈 길 = 틀린 자리만 M01 식 + 짝잃음 · 이름 겹친 변수 = 뺌."""
doc = _sk_doc(
CARRY,
_carry(),
변수줄=[{"이름": "무게", "식": "1", "자리": ""}],
식덮개={MAN: "무게 / 없는것", "mid:없는줄": "1"},
)
got = _run(doc)
assert got["기준"]["계"] == 42917
why = {(x["cid"], x["까닭"]) for x in got["짝잃음"]}
assert (MAN, "식 — 모르는 이름 「없는것」") in why
assert ("mid:없는줄", "식 — 바탕에 없는 줄") in why
assert any(c == "mid:무게" and "이미 있음" in w for c, w in why)
# ── 옛 SK · 저장 · 셈 답 · 펼침 · 초기화 ─────────────────────────────────
def test_옛_SK_그대로():
whole = _whole()
old = _sk_doc(
FORM, F211, 덮개={"labor:LB000007#0": {"qty": 0.2}}, 상수덮개={"mid:시공량": {"2": 20}}
)
got = sd.clean(old, None, whole)
assert not set(got) & set(fx.FIELDS) and not fx.has(got)
row = whole.logic(FORM)
assert fx.settle(row, got) == (row, [], got)
assert _sum(_doc(FORM, F211)) == 51419 and _sum(old) == _sum({**old, "식덮개": {}})
def _sk(folder, **doc):
with store.at(folder):
made = refs.create("sangeun", {"이름": "내 단가산출", "단위": "식", "더한줄": [], **doc})
return made["이름"]
def _read(folder, key):
with store.at(folder):
return store.read("sangeun", key)["문서"]
def _calc(folder, doc):
with store.at(folder):
return calc.calc_doc(doc)
def test_저장_엔진_식_셈_답(world):
mine = world["personal"]
add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 ÷ 일운반회수"}
var = [{"이름": "할증율", "식": "20", "자리": ""}]
key = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=var, 식덮개={MAN: PLUS})
doc = _read(mine, key)
assert doc["식덮개"] == {MAN: PLUS.replace("×", "*")} and doc["변수줄"] == var
got = _calc(mine, doc)
assert got["기준"]["계"] == 51501 and got["원래"]["계"] == 42917
spot = got["시트"]["자리"][MAN]
assert spot["식덮개"] is True and spot["덮개"] == ["식"] and spot["식글"] == PLUS
assert spot["원식글"] == "무게 / 운반량 / 일운반회수" and spot["식"] == spot["원식글"]
v = got["시트"]["자리"]["mid:할증율"]
assert v["갈래"] == "변수" and v["식글"] == "20" and float(v["지금"]) == 20
names = {(x["글"], x["갈래"]) for x in got["시트"]["이름표"]}
assert {("할증율", "변수"), ("[인력운반기본공식]", "별칭"), ("노무비", "비목")} <= names
assert ("[노임 보통인부]", "자원") in names # 호표 요소
key2 = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 더한줄=[add])
doc2 = _read(mine, key2)
assert doc2["더한줄"][0]["수량식"] == "1 / 일운반회수"
assert _calc(mine, doc2)["시트"]["자리"]["add:#0"]["식글"] == "1 / 일운반회수"
with store.at(mine), pytest.raises(store.StoreError) as e: # 틀린 식 저장 = 400
refs.create("sangeun", {**_plus(식덮개={MAN: "무게 / 없는것"}), "더한줄": []})
assert e.value.status == 400 and "모르는 이름" in str(e.value.detail)
def test_펼침_막음_초기화_걷음(world):
mine = world["personal"]
a = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 변수줄=[{"이름": "할증율", "식": "20"}])
with store.at(mine), pytest.raises(store.StoreError):
flat.flat_lines("sangeun", {**MINE, "키": a})
doc, n = flat.reset("sangeun", {**_plus(), "까닭": {MAN: "할증"}})
assert n == 1 and "식덮개" not in doc and "까닭" not in doc and doc["변수줄"]
assert const.restore(_plus(), MAN).get("식덮개") is None
assert const.kinds(_plus(), MAN) == ["식"] and const.covers_n(_plus()) == 1
# ── B09 사본 굳힘 ─────────────────────────────────────────────────────
def test_B09_굳힘_식_칸_그대로():
whole = _whole()
add = {"종류": "인력", "요소": "LB000002", "수량": 1, "수량식": "1 / 일운반회수"}
doc = sd.clean({**_plus(), "더한줄": [add]}, None, whole)
live = calc.run(doc, whole)["기준"]["계"]
rid = "personal|7/42|SK-000001"
frozen = sk.freeze({rid: doc})[rid]
row, key = frozen[sk.ROW], frozen["바탕"]["로직"]
assert key.startswith("GX9") and frozen["원본"]["식덮개"] == doc["식덮개"]
assert [x["이름"] for x in row["중간"]][0] == "할증율"
assert row["호표"][0]["수량"] == PLUS.replace("×", "*")
assert row["호표"][1]["수량"] == "1 / 일운반회수"
over = calc._Over(whole, key, json.loads(json.dumps(row)))
again = calc.run(sd.clean(frozen, None, over), over)["기준"]["계"]
assert again == live and float(live) > 51501
# ── TS ───────────────────────────────────────────────────────────────
def _ts(body: str):
src = "".join(
(ROOT / f"M02_MasterTemplete/logic/M02_Sangeun_{n}_Model.ts").read_text("utf-8")
for n in ("Const", "Formula")
)
src = "\n".join(x for x in src.splitlines() if not x.startswith("import type"))
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def test_TS_고치기_꼴_서버가_받음():
ts = _ts(
f"\nlet d = {json.dumps(_doc(CARRY, _carry()), ensure_ascii=False)};"
"\nd = {...d, 더한줄: [{종류: '인력', 요소: 'LB000002', 수량: 1}]};"
"\nd = addVar(d, {이름: '할증', 식: '10', 자리: ''});"
"\nd = addVar(d, {이름: '덤', 식: '0', 자리: 'mid:할증'});"
"\nd = editVar(d, '할증', {이름: '할증율', 식: '20'});"
"\nd = setFormula(d, 'mid:할증율', '20'); d = dropVar(d, '덤');"
f"\nd = setFormula(d, '{MAN}', '{PLUS}'); d = setFormula(d, 'mid:운반량', '25');"
"\nd = setFormula(d, 'mid:운반량', null); d = setFormula(d, 'add:#0', '1 / 일운반회수');"
"\nd = setReason(d, 'mid:할증율', '할증');"
f"\nconst out = {{d, r: restoreM01(d, '{MAN}'), k: coverKinds(d, '{MAN}')}};"
"\nconsole.log(JSON.stringify(out));"
)
d = ts["d"]
assert d["변수줄"] == [{"이름": "할증율", "식": "20", "자리": ""}]
assert d["식덮개"] == {MAN: PLUS} and d["까닭"] == {"mid:할증율": "할증"}
got = sd.clean(d, None, _whole())
assert {k: got[k] for k in fx.FIELDS} == {k: d[k] for k in fx.FIELDS}
assert got["더한줄"][0]["수량식"] == "1 / 일운반회수"
assert _sum(d) > 51501
assert ts["r"] == const.restore(d, MAN) and ts["k"] == const.kinds(d, MAN) == ["식"]
def test_같은_내용_지문_식_칸_가림():
"""식 칸만 다른 SK = 다른 내용(409 같은 것 물음 안 함) · 옛 SK 지문 그대로."""
from M02_MasterTemplete import M02_Library_Same as same
plain = _doc(CARRY, _carry())
one, two = same.finger("sangeun", plain), same.finger("sangeun", _plus())
assert one and two and one != two
assert same.finger("sangeun", {**plain, "식덮개": {}, "변수줄": []}) == one