Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Cond.css — 조건 보고 고치기 창
|
||||
* ========================================================================== */
|
||||
|
||||
.m01-easycond {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.m01-easycond__lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m01-easycond__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.m01-easycond__slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.m01-easycond__select,
|
||||
.m01-easycond__num {
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border, #c9ced6);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.m01-easycond__num {
|
||||
width: 7em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-easycond__num.is-bad {
|
||||
border-color: var(--color-danger, #d64545);
|
||||
outline: 2px solid var(--color-danger, #d64545);
|
||||
}
|
||||
|
||||
.m01-easycond__word {
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised, #eef1f5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easycond__chip {
|
||||
min-height: 36px;
|
||||
padding: 4px 12px;
|
||||
border: 1px dashed var(--color-accent, #3a6fd8);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-accent, #3a6fd8);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycond__chip:disabled {
|
||||
border-color: var(--color-border, #c9ced6);
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.m01-easycond__money {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised, #eef1f5);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easycond__money.is-waiting {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m01-easycond__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.m01-easycond__cannot {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Cond.ts
|
||||
* 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고
|
||||
* 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null
|
||||
*
|
||||
* 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음
|
||||
* 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리)
|
||||
* 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로
|
||||
* 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로)
|
||||
* 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄
|
||||
* 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
runCalc,
|
||||
type CalcAnswer,
|
||||
type LogicInput,
|
||||
type LogicRow,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { calcLater } from "./M01_EasyCalc_Api";
|
||||
import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Cond.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["조건 보고 고치기", "Change by conditions"],
|
||||
Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"],
|
||||
Part: ["조건", "Condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니고 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Else: ["아니면 →", "Otherwise →"],
|
||||
Subject: ["가", "is"],
|
||||
Big: ["가운데 큰 값", "whichever is larger"],
|
||||
Small: ["가운데 작은 값", "whichever is smaller"],
|
||||
And: ["와", "and"],
|
||||
RoundA: ["을 소수", "to"],
|
||||
RoundB: ["자리까지", "decimal places,"],
|
||||
Up: ["올림", "round up"],
|
||||
Down: ["버림", "round down"],
|
||||
Half: ["반올림", "round off"],
|
||||
OpEq: ["이면", "equal to"],
|
||||
OpNe: ["이 아니면", "not equal to"],
|
||||
OpGt: ["보다 크면", "greater than"],
|
||||
OpGe: ["이상이면", "at least"],
|
||||
OpLt: ["보다 작으면", "less than"],
|
||||
OpLe: ["이하이면", "at most"],
|
||||
ChipTable: ["표에서 찾은 값", "Value from table"],
|
||||
ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"],
|
||||
ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"],
|
||||
ChipCalc: ["계산한 값", "Calculated value"],
|
||||
ToNumber: ["숫자로", "Use a number"],
|
||||
Line: ["이 줄", "This line"],
|
||||
Total: ["합계", "Total"],
|
||||
Was: ["전", "before"],
|
||||
Waiting: ["계산 중 …", "Calculating …"],
|
||||
Use: ["이걸로", "Use this"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
NotNumber: ["숫자만 넣음", "Numbers only"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */
|
||||
|
||||
/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */
|
||||
type CNode = { src: string; p: [number, number] } & (
|
||||
| { t: "num" | "str" | "name" | "ref"; v: string }
|
||||
| { t: "call"; f: string; items: CNode[] }
|
||||
| { t: "bin"; op: string; a: CNode; b: CNode }
|
||||
| { t: "neg" | "paren"; a: CNode }
|
||||
| { t: "field"; a: CNode; f: string }
|
||||
);
|
||||
|
||||
const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"];
|
||||
const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"];
|
||||
const NAME_RE = /[\p{L}\p{N}_]+/uy;
|
||||
const NUM_RE = /\d+(?:\.\d+)?/y;
|
||||
|
||||
/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */
|
||||
function parseCond(s: string): CNode | null {
|
||||
let i = 0;
|
||||
const fail = (): never => {
|
||||
throw new Error("parse");
|
||||
};
|
||||
const ws = (): void => {
|
||||
while (i < s.length && /\s/.test(s[i])) i++;
|
||||
};
|
||||
const take = (w: string): boolean => {
|
||||
ws();
|
||||
if (!s.startsWith(w, i)) return false;
|
||||
i += w.length;
|
||||
return true;
|
||||
};
|
||||
const need = (w: string): void => void (take(w) || fail());
|
||||
const mk = <T extends object>(start: number, body: T): CNode =>
|
||||
({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode;
|
||||
const sticky = (re: RegExp): string | null => {
|
||||
re.lastIndex = i;
|
||||
const m = re.exec(s);
|
||||
if (m) i = re.lastIndex;
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
const word = (): string => (ws(), sticky(NAME_RE) ?? fail());
|
||||
const quoted = (): string => {
|
||||
const end = s.indexOf("'", i);
|
||||
if (end < 0) fail();
|
||||
const v = s.slice(i, end);
|
||||
i = end + 1;
|
||||
return v;
|
||||
};
|
||||
/** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */
|
||||
const skipGroup = (): void => {
|
||||
let depth = 0;
|
||||
let q = false;
|
||||
for (; i < s.length; i++) {
|
||||
if (s[i] === "'") q = !q;
|
||||
else if (!q && s[i] === "(") depth++;
|
||||
else if (!q && s[i] === ")" && --depth === 0) return void i++;
|
||||
}
|
||||
fail();
|
||||
};
|
||||
/** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */
|
||||
const skipRef = (): void => {
|
||||
for (let depth = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if ("({".includes(ch)) depth++;
|
||||
else if (")}".includes(ch) && depth-- === 0) return;
|
||||
else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return;
|
||||
else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return;
|
||||
}
|
||||
};
|
||||
const fields = (start: number, node: CNode): CNode => {
|
||||
while (take(".")) {
|
||||
const f = take("'") ? quoted() : word();
|
||||
node = mk(start, { t: "field", a: node, f });
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const primary = (): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
if (take("(")) {
|
||||
const a = expr();
|
||||
need(")");
|
||||
return fields(start, mk(start, { t: "paren", a }));
|
||||
}
|
||||
if (take("'")) return mk(start, { t: "str", v: quoted() });
|
||||
const num = sticky(NUM_RE);
|
||||
if (num) return mk(start, { t: "num", v: num });
|
||||
const w = word();
|
||||
if (w === "찾기" || w === "로직") {
|
||||
ws();
|
||||
if (s[i] !== "(") fail();
|
||||
skipGroup();
|
||||
return fields(start, mk(start, { t: "ref", v: w }));
|
||||
}
|
||||
if (COND_FUNCS.includes(w)) {
|
||||
need("(");
|
||||
const items = [expr()];
|
||||
while (take(",")) items.push(expr());
|
||||
need(")");
|
||||
return fields(start, mk(start, { t: "call", f: w, items }));
|
||||
}
|
||||
if (s[i] === ":") {
|
||||
i++;
|
||||
skipRef();
|
||||
return fields(start, mk(start, { t: "ref", v: w }));
|
||||
}
|
||||
return fields(start, mk(start, { t: "name", v: w }));
|
||||
};
|
||||
const unary = (): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
return take("-") ? mk(start, { t: "neg", a: unary() }) : primary();
|
||||
};
|
||||
const chain = (next: () => CNode, ops: string[], once = false): CNode => {
|
||||
ws();
|
||||
const start = i;
|
||||
let node = next();
|
||||
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
|
||||
node = mk(start, { t: "bin", op, a: node, b: next() });
|
||||
return node;
|
||||
};
|
||||
const mul = (): CNode => chain(unary, ["*", "/"]);
|
||||
const add = (): CNode => chain(mul, ["+", "-"]);
|
||||
const compare = (): CNode => chain(add, COMPARE_OPS, true);
|
||||
const both = (): CNode => chain(compare, ["그리고"]);
|
||||
const expr = (): CNode => chain(both, ["또는"]);
|
||||
try {
|
||||
const node = expr();
|
||||
ws();
|
||||
return i === s.length ? node : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */
|
||||
export type CondSlot =
|
||||
| { kind: "num"; value: number }
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "input"; name: string }
|
||||
| { kind: "table" | "calc"; src: string };
|
||||
export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<=";
|
||||
export interface CondTest {
|
||||
left: CondSlot;
|
||||
op: CondOp;
|
||||
right: CondSlot;
|
||||
}
|
||||
export type CondShape =
|
||||
| { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot }
|
||||
| { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] }
|
||||
| {
|
||||
type: "round";
|
||||
fn: "올림" | "버림" | "반올림";
|
||||
value: CondSlot;
|
||||
places: number;
|
||||
hadPlaces: boolean;
|
||||
};
|
||||
/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */
|
||||
export interface CondBlock {
|
||||
shape: CondShape;
|
||||
p: [number, number];
|
||||
}
|
||||
/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */
|
||||
export interface CondRead {
|
||||
text: string;
|
||||
via: string | null;
|
||||
blocks: CondBlock[];
|
||||
}
|
||||
|
||||
const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n);
|
||||
|
||||
function slotOf(node: CNode, inputs: LogicInput[]): CondSlot {
|
||||
const n = unparen(node);
|
||||
if (n.t === "num") return { kind: "num", value: Number(n.v) };
|
||||
if (n.t === "neg" && unparen(n.a).t === "num")
|
||||
return { kind: "num", value: -Number(unparen(n.a).src) };
|
||||
if (n.t === "str") return { kind: "text", value: n.v };
|
||||
if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v };
|
||||
if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기"))
|
||||
return { kind: "table", src: n.src };
|
||||
return { kind: "calc", src: node.src.trim() };
|
||||
}
|
||||
|
||||
function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null {
|
||||
if (n.t !== "call") return null;
|
||||
const slot = (x: CNode): CondSlot => slotOf(x, inputs);
|
||||
if (n.f === "만약") {
|
||||
const t = unparen(n.items[0]);
|
||||
if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null;
|
||||
const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) };
|
||||
const branch = { test, value: slot(n.items[1]) };
|
||||
const rest = shapeOf(unparen(n.items[2]), inputs);
|
||||
return rest?.type === "if"
|
||||
? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise }
|
||||
: { type: "if", branches: [branch], otherwise: slot(n.items[2]) };
|
||||
}
|
||||
if (n.f === "최소" || n.f === "최대")
|
||||
return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null;
|
||||
const places = n.items[1] ? unparen(n.items[1]) : null;
|
||||
if (n.items.length > 2 || (places && places.t !== "num")) return null;
|
||||
const fn = n.f as "올림" | "버림" | "반올림";
|
||||
const value = slot(n.items[0]);
|
||||
return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places };
|
||||
}
|
||||
|
||||
const condNum = (n: number): string => {
|
||||
const t = String(Number(n.toPrecision(12)));
|
||||
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
|
||||
};
|
||||
|
||||
function slotText(s: CondSlot): string {
|
||||
if (s.kind === "num") return condNum(s.value);
|
||||
if (s.kind === "text") return `'${s.value}'`;
|
||||
return s.kind === "input" ? s.name : s.src;
|
||||
}
|
||||
|
||||
/** 덩어리 → 식 글 */
|
||||
export function writeCond(shape: CondShape): string {
|
||||
if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`;
|
||||
if (shape.type === "round") {
|
||||
const v = slotText(shape.value);
|
||||
return shape.places === 0 && !shape.hadPlaces
|
||||
? `${shape.fn}(${v})`
|
||||
: `${shape.fn}(${v}, ${condNum(shape.places)})`;
|
||||
}
|
||||
return shape.branches.reduceRight((acc, b) => {
|
||||
const t = b.test;
|
||||
return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`;
|
||||
}, slotText(shape.otherwise));
|
||||
}
|
||||
|
||||
const flat = (s: string): string => s.replace(/[\s()]+/g, "");
|
||||
|
||||
/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */
|
||||
function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] {
|
||||
if (n.t === "call") {
|
||||
const shape = shapeOf(n, inputs);
|
||||
if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }];
|
||||
return n.items.flatMap((x) => blocksOf(x, inputs));
|
||||
}
|
||||
if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)];
|
||||
if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */
|
||||
export function readCond(formula: string, row: Pick<LogicRow, "입력" | "중간">): CondRead | null {
|
||||
let text = formula.trim();
|
||||
let via: string | null = null;
|
||||
for (let hop = 0; hop < 5; hop++) {
|
||||
const mid = (row.중간 ?? []).find((m) => m.이름 === text);
|
||||
if (!mid) break;
|
||||
via = mid.이름;
|
||||
text = mid.식.trim();
|
||||
}
|
||||
const node = parseCond(text);
|
||||
const blocks = node ? blocksOf(node, row.입력 ?? []) : [];
|
||||
return blocks.length ? { text, via, blocks } : null;
|
||||
}
|
||||
|
||||
/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */
|
||||
export function writeRead(read: CondRead): string {
|
||||
let out = read.text;
|
||||
for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0]))
|
||||
out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 화면 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OPS: [CondOp, keyof typeof TEXT][] = [
|
||||
["==", "OpEq"],
|
||||
["!=", "OpNe"],
|
||||
[">", "OpGt"],
|
||||
[">=", "OpGe"],
|
||||
["<", "OpLt"],
|
||||
["<=", "OpLe"],
|
||||
];
|
||||
|
||||
/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */
|
||||
const TABLE_MODULE = import.meta.glob<{
|
||||
openEasyTable: (
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial?: Record<string, unknown>,
|
||||
) => Promise<string | null>;
|
||||
}>("./M01_EasyCalc_Table.ts");
|
||||
|
||||
/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */
|
||||
const put = (slot: CondSlot, next: CondSlot): void => {
|
||||
for (const k of Object.keys(slot)) delete (slot as Record<string, unknown>)[k];
|
||||
Object.assign(slot, next);
|
||||
};
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
): HTMLSelectElement {
|
||||
const sel = el("select", { className: "m01-easycond__select" });
|
||||
const all = options.some((o) => o.value === value)
|
||||
? options
|
||||
: [...options, { value, text: value }];
|
||||
for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } }));
|
||||
sel.value = value;
|
||||
sel.addEventListener("change", () => onChange(sel.value));
|
||||
return sel;
|
||||
}
|
||||
|
||||
function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: "m01-easycond__num",
|
||||
attrs: { type: "text", inputmode: "decimal", value: condNum(value) },
|
||||
});
|
||||
box.addEventListener("input", () => {
|
||||
const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value);
|
||||
box.classList.toggle("is-bad", !ok);
|
||||
box.title = ok ? "" : te("NotNumber");
|
||||
if (ok) onChange(Number(box.value));
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
function chipLabel(slot: CondSlot, inputs: LogicInput[]): string {
|
||||
if (slot.kind === "table") return te("ChipTable");
|
||||
if (slot.kind !== "calc") return "";
|
||||
if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc");
|
||||
const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
||||
return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc");
|
||||
}
|
||||
|
||||
/** 문장 짓기 — 고칠 때마다 changed() */
|
||||
class Sentence {
|
||||
constructor(
|
||||
private card: EasyCard,
|
||||
private doc: EasyDoc,
|
||||
private changed: () => void,
|
||||
private trial: Record<string, unknown>,
|
||||
) {}
|
||||
|
||||
/** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */
|
||||
slot(s: CondSlot): HTMLElement {
|
||||
const host = el("span", { className: "m01-easycond__slot" });
|
||||
const draw = (): void => {
|
||||
host.replaceChildren();
|
||||
if (s.kind === "num") {
|
||||
host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed())));
|
||||
return;
|
||||
}
|
||||
if (s.kind === "text" || s.kind === "input") {
|
||||
const text = s.kind === "text" ? s.value : s.name;
|
||||
host.append(el("span", { className: "m01-easycond__word", text }));
|
||||
return;
|
||||
}
|
||||
const chip = el("button", {
|
||||
className: "m01-easycond__chip",
|
||||
text: chipLabel(s, this.doc.inputs),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"];
|
||||
if (load && s.kind === "table")
|
||||
chip.addEventListener("click", async () => {
|
||||
const got = await (
|
||||
await load()
|
||||
).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial);
|
||||
if (got) (put(s, { kind: "table", src: got }), draw(), this.changed());
|
||||
});
|
||||
else chip.disabled = true;
|
||||
const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed());
|
||||
host.append(
|
||||
chip,
|
||||
createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
return host;
|
||||
}
|
||||
|
||||
/** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */
|
||||
test(t: CondTest): HTMLElement[] {
|
||||
const inputs = this.doc.inputs;
|
||||
const right = el("span", { className: "m01-easycond__slot" });
|
||||
const drawRight = (): void => {
|
||||
const name = t.left.kind === "input" ? t.left.name : null;
|
||||
const inp = inputs.find((x) => x.이름 === name);
|
||||
const choices = inp?.고르기 ?? [];
|
||||
const r = t.right;
|
||||
if (!choices.length || (r.kind !== "text" && r.kind !== "num"))
|
||||
return right.replaceChildren(this.slot(r));
|
||||
const pickOne = (v: string): void => {
|
||||
const asNum = choices.some((c) => typeof c === "number" && String(c) === v);
|
||||
put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v });
|
||||
this.changed();
|
||||
};
|
||||
const opts = choices.map((c) => ({ value: String(c), text: String(c) }));
|
||||
right.replaceChildren(select(opts, String(r.value), pickOne));
|
||||
};
|
||||
drawRight();
|
||||
const l = t.left;
|
||||
const left =
|
||||
l.kind === "input"
|
||||
? select(
|
||||
inputs.map((x) => ({ value: x.이름, text: x.이름 })),
|
||||
l.name,
|
||||
(v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()),
|
||||
)
|
||||
: this.slot(l);
|
||||
const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) }));
|
||||
const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed()));
|
||||
return [left, el("span", { text: te("Subject") }), right, op];
|
||||
}
|
||||
|
||||
/** 덩어리 하나 → 줄들 */
|
||||
block(shape: CondShape): HTMLElement[] {
|
||||
if (shape.type === "if")
|
||||
return [
|
||||
...shape.branches.map((b, i) =>
|
||||
line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)),
|
||||
),
|
||||
line(te("Else"), this.slot(shape.otherwise)),
|
||||
];
|
||||
if (shape.type === "pick") {
|
||||
const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)]));
|
||||
const opts = [
|
||||
{ value: "최대", text: te("Big") },
|
||||
{ value: "최소", text: te("Small") },
|
||||
];
|
||||
const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed()));
|
||||
return [line(...kids, fn)];
|
||||
}
|
||||
const ways = [
|
||||
{ value: "올림", text: te("Up") },
|
||||
{ value: "버림", text: te("Down") },
|
||||
{ value: "반올림", text: te("Half") },
|
||||
];
|
||||
const setPlaces = (v: number): void => (
|
||||
(shape.places = Math.max(0, Math.round(v))),
|
||||
this.changed()
|
||||
);
|
||||
const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed()));
|
||||
const places = numberBox(shape.places, setPlaces);
|
||||
return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)];
|
||||
}
|
||||
}
|
||||
|
||||
const line = (...kids: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-easycond__line",
|
||||
children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)),
|
||||
});
|
||||
|
||||
/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */
|
||||
function moneyView(card: EasyCard, doc: EasyDoc, trial: Record<string, unknown>) {
|
||||
const box = el("div", { className: "m01-easycond__money", text: te("Waiting") });
|
||||
const ho = doc.cards.filter(
|
||||
(c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"),
|
||||
);
|
||||
const at = ho.findIndex((c) => c.id === card.id);
|
||||
let first: [number | null, number | null] | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const show = (ans: CalcAnswer | null, plain: string | null): void => {
|
||||
box.classList.remove("is-waiting");
|
||||
if (!ans || !ans.ok)
|
||||
return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : ""));
|
||||
const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null;
|
||||
const sums = ans.sums ?? {};
|
||||
const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0);
|
||||
first ??= [lineSum, total];
|
||||
const part = (label: string, v: number | null, was: number | null): string =>
|
||||
v === null
|
||||
? ""
|
||||
: `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`;
|
||||
const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])];
|
||||
box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t })));
|
||||
};
|
||||
const update = (text: string): void => {
|
||||
box.classList.add("is-waiting");
|
||||
const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null }));
|
||||
const mine = ++seq;
|
||||
const done = (ans: CalcAnswer | null, plain: string | null = null): void =>
|
||||
void (mine === seq && show(ans, plain));
|
||||
try {
|
||||
calcLater(row, trial, done);
|
||||
} catch {
|
||||
// Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만)
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
runCalc({ key: "", inputs: trial, row }).then(
|
||||
(a) => done(a),
|
||||
() => done(null),
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
return { box, update };
|
||||
}
|
||||
|
||||
/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */
|
||||
export function openEasyCond(
|
||||
card: EasyCard,
|
||||
doc: EasyDoc,
|
||||
trial: Record<string, unknown> = {},
|
||||
): Promise<string | null> {
|
||||
const read = readCond(card.qtyFormula ?? "", doc.row);
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v: string | null): void => {
|
||||
if (!done) ((done = true), resolve(v));
|
||||
};
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Cancel"),
|
||||
dialogClass: "m01-easycond",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
if (!read)
|
||||
return void body.append(
|
||||
el("p", { className: "m01-easycond__cannot", text: te("Cannot") }),
|
||||
);
|
||||
const base = flat(writeRead(read));
|
||||
const money = moneyView(card, doc, trial);
|
||||
const use = createButton({
|
||||
label: te("Use"),
|
||||
variant: "filled",
|
||||
disabled: true,
|
||||
onClick: () => (finish(writeRead(read)), close()),
|
||||
});
|
||||
const changed = (): void => {
|
||||
const text = writeRead(read);
|
||||
use.disabled = flat(text) === base;
|
||||
money.update(text);
|
||||
};
|
||||
const say = new Sentence(card, doc, changed, trial);
|
||||
const lines = el("div", { className: "m01-easycond__lines" });
|
||||
read.blocks.forEach((b, i) => {
|
||||
if (read.blocks.length > 1)
|
||||
lines.append(
|
||||
el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }),
|
||||
);
|
||||
lines.append(...say.block(b.shape));
|
||||
});
|
||||
const cancel = createButton({
|
||||
label: te("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => (finish(null), close()),
|
||||
});
|
||||
const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] });
|
||||
body.append(lines, money.box, foot);
|
||||
money.update(writeRead(read));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA.
|
||||
|
||||
· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
|
||||
· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
INPUTS = [
|
||||
{"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]},
|
||||
{"이름": "뒷길이", "단위": "㎝"},
|
||||
{"이름": "교환측", "고르기": ["양측", "한측"]},
|
||||
{"이름": "층수", "고르기": [1, 2, 3]},
|
||||
]
|
||||
ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]}
|
||||
|
||||
|
||||
def _run(body: str, *args: object) -> object:
|
||||
"""읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답"""
|
||||
src = COND.read_text(encoding="utf-8")
|
||||
part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")]
|
||||
script = (
|
||||
"type LogicInput = any; type LogicRow = any;\n"
|
||||
+ part
|
||||
+ "\nconst A = JSON.parse(process.argv[1]);\n"
|
||||
+ "const out = (x: unknown) => console.log(JSON.stringify(x));\n"
|
||||
+ body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _read(formula: str) -> dict | None:
|
||||
return _run("out(readCond(A[0], A[1]));", formula, ROW)
|
||||
|
||||
|
||||
def _edit(formula: str, js: str) -> str:
|
||||
"""읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬"""
|
||||
return _run(
|
||||
f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));",
|
||||
formula,
|
||||
ROW,
|
||||
)
|
||||
|
||||
|
||||
# ── 읽기 ───────────────────────────────────────────────────────────────────
|
||||
def test_만약_한겹_설계값_표() -> None:
|
||||
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
|
||||
r = _read(f)
|
||||
assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1
|
||||
b = r["blocks"][0]
|
||||
assert b["p"] == [0, len(f)]
|
||||
assert b["shape"] == {
|
||||
"type": "if",
|
||||
"branches": [
|
||||
{
|
||||
"test": {
|
||||
"left": {"kind": "input", "name": "비탈구배"},
|
||||
"op": "==",
|
||||
"right": {"kind": "text", "value": "해당 없음"},
|
||||
},
|
||||
"value": {"kind": "num", "value": 0},
|
||||
}
|
||||
],
|
||||
"otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"},
|
||||
}
|
||||
|
||||
|
||||
def test_만약_사슬() -> None:
|
||||
r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))")
|
||||
shape = r["blocks"][0]["shape"]
|
||||
assert [x["value"] for x in shape["branches"]] == [
|
||||
{"kind": "num", "value": 2},
|
||||
{"kind": "num", "value": 3.5},
|
||||
]
|
||||
assert shape["otherwise"] == {"kind": "num", "value": -1}
|
||||
assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2}
|
||||
|
||||
|
||||
def test_최대_올림() -> None:
|
||||
pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"]
|
||||
assert pick == {
|
||||
"type": "pick",
|
||||
"fn": "최대",
|
||||
"items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}],
|
||||
}
|
||||
up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"]
|
||||
assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True)
|
||||
assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"}
|
||||
assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False
|
||||
|
||||
|
||||
def test_식_속_덩어리와_중간값() -> None:
|
||||
f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)"
|
||||
r = _read(f)
|
||||
assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)]
|
||||
many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100")
|
||||
assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"]
|
||||
via = _read("석공품")
|
||||
assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"formula",
|
||||
[
|
||||
"만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)",
|
||||
"찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량",
|
||||
"찾기(QC000001).값 * 2",
|
||||
"0.5",
|
||||
"만약(뒷길이 > 40, 1",
|
||||
"",
|
||||
],
|
||||
ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"],
|
||||
)
|
||||
def test_못_읽는_꼴(formula: str) -> None:
|
||||
assert _read(formula) is None
|
||||
|
||||
|
||||
# ── 짜기 왕복 ───────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize(
|
||||
"formula",
|
||||
[
|
||||
"만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)",
|
||||
"만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))",
|
||||
"최소(시공량 * 0.5, 3, 뒷길이)",
|
||||
"반올림(찾기(QC000001).값 / 시공량, 2)",
|
||||
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)",
|
||||
"(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100",
|
||||
],
|
||||
)
|
||||
def test_안_고치면_같은_글(formula: str) -> None:
|
||||
assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula
|
||||
|
||||
|
||||
def test_고친_뒤_짜기() -> None:
|
||||
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
|
||||
assert (
|
||||
_edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='")
|
||||
== "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)"
|
||||
)
|
||||
assert (
|
||||
_edit(f, "B.otherwise = { kind: 'num', value: 0.25 }")
|
||||
== "만약(비탈구배 == '해당 없음', 0, 0.25)"
|
||||
)
|
||||
assert (
|
||||
_edit(
|
||||
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)",
|
||||
"B.branches[0].value.value = 1.1",
|
||||
)
|
||||
== "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)"
|
||||
)
|
||||
assert (
|
||||
_edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4")
|
||||
== "최소(시공량 * 0.5, 4)"
|
||||
)
|
||||
assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)"
|
||||
assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == (
|
||||
"만약(층수 > 40, 0.4, 0.2)"
|
||||
)
|
||||
|
||||
|
||||
def test_둘째_덩어리_고침() -> None:
|
||||
f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100"
|
||||
got = _run(
|
||||
"const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));",
|
||||
f,
|
||||
ROW,
|
||||
)
|
||||
assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100"
|
||||
|
||||
|
||||
# ── 화면 글 ─────────────────────────────────────────────────────────────────
|
||||
def test_화면_글에_금지_낱말_없음() -> None:
|
||||
src = COND.read_text(encoding="utf-8")
|
||||
block = src[src.index("const TEXT = {") : src.index("} as const;")]
|
||||
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
|
||||
assert len(korean) >= 30
|
||||
for text in korean:
|
||||
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
|
||||
assert bad not in text, (bad, text)
|
||||
Reference in New Issue
Block a user