feat(m02-cf): 입력 상자 판 InputPanel 초안 — 그룹 요소 · 표 · 변수 · 사용자 요소

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-10-01 16:07:44 +09:00
co-authored by Claude Sonnet 5.5
parent d761e2d543
commit ad1d104776
6 changed files with 1576 additions and 0 deletions
@@ -0,0 +1,145 @@
.m02-cfip {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
color: var(--color-text);
font-size: var(--text-body-lg, 1.05rem);
}
.m02-cfip h4,
.m02-cfip h5 {
margin: 0;
color: var(--color-text-secondary);
}
.m02-cfip h4 {
font-size: 1.15em;
color: var(--color-text);
}
.m02-cfip__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-cfip__unit {
margin: 0;
font-weight: 600;
}
.m02-cfip__body,
.m02-cfip__chosen,
.m02-cfip__find,
.m02-cfip__stack,
.m02-cfip__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-cfip__list {
max-height: 28vh;
overflow: auto;
}
.m02-cfip__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m02-cfip__chip,
.m02-cfip__row {
padding: var(--spacing-8) var(--spacing-12);
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.m02-cfip__chip {
border-radius: var(--radius-pills);
padding: var(--spacing-4) var(--spacing-12);
}
.m02-cfip__chip.is-on {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent);
}
.m02-cfip__chip:hover,
.m02-cfip__chip:focus-visible,
.m02-cfip__row:hover,
.m02-cfip__row:focus-visible {
border-color: var(--color-accent);
}
.m02-cfip__row {
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
text-align: left;
}
.m02-cfip__rowbox {
display: flex;
gap: var(--spacing-8);
align-items: center;
}
.m02-cfip__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m02-cfip__bar > :last-child {
flex: 1 1 180px;
}
.m02-cfip__item {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m02-cfip__name {
flex: 1 1 auto;
min-width: 0;
}
.m02-cfip__value {
color: var(--color-accent);
}
.m02-cfip__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
font-size: var(--text-caption);
}
.m02-cfip__new,
.m02-cfip__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
}
.m02-cfip__new > summary {
cursor: pointer;
font-weight: 600;
}
.m02-cfip__preview table {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-cfip__preview th,
.m02-cfip__preview td {
padding: 2px 8px;
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: center;
}
.m02-cfip__preview tr.is-hit td {
background: rgba(240, 190, 40, 0.35);
font-weight: 700;
}
.m02-cfip__preview {
max-height: 24vh;
overflow: auto;
}
@@ -0,0 +1,418 @@
/* =============================================================================
* M02_CF_InputPanel.ts
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/cf/user-elements` = 시험 폴더 `_data`)
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubs,
type SubInfo,
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
import { cfPlain, cfUserElementAdd, cfUserElements, cfValues, type CfQuery } from "./M02_CF_Api";
import type { CfBox, CfGroup, CfLogic } from "./M02_CF_Model";
import {
GROUPS,
M01_OF,
USER_PREFIX,
addElement,
elementOf,
machineFields,
queryOf,
removeElement,
setKind,
valueOf,
type M01Row,
} from "./M02_CF_InputPanel_Build";
import { renderTablePick } from "./M02_CF_InputPanel_Table";
import { renderVariables } from "./M02_CF_InputPanel_Vars";
import "./M02_CF_InputPanel.css";
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
유가전력: ["유가·전력", "Fuel · power"],
기계: ["기계", "Machine"],
소요량: ["소요량", "Quantity table"],
계수: ["계수", "Factor table"],
환율: ["환율", "Exchange rate"],
요율: ["요율", "Rate"],
Chosen: [
"넣은 요소 — 알약마다 이름 · 값 · 단위",
"Chosen elements — name · value · unit per pill",
],
NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"],
Remove: ["빼기", "Remove"],
Put: ["넣기", "Add"],
Search: ["이름으로 찾기", "Search by name"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Loading: ["찾는 중…", "Searching…"],
NoRow: ["찾는 요소 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
Mine: ["내가 만든 것", "Mine"],
NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"],
NewName: ["이름", "Name"],
NewSpec: ["규격", "Spec"],
NewUnit: ["단위", "Unit"],
NewValue: ["값(숫자)", "Value (number)"],
Make: ["만들어 넣기", "Create and add"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
NeedValue: ["값은 숫자로", "Value must be a number"],
BoxUnit: ["상자 단위", "Box unit"],
Field: ["값 칸", "Value"],
NoValue: ["—", "—"],
Failed: ["불러오지 못함", "Could not load"],
Pills: ["알약 {n} 개", "{n} pills"],
NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */
const plain = (e: unknown): string => {
try {
return cfPlain(e);
} catch {
return e instanceof Error ? e.message : t("Failed");
}
};
const fileCache = new Map<string, string>();
/** 그룹의 M01 파일 이름 */
async function fileOf(group: CfGroup): Promise<string> {
const hit = fileCache.get(group);
if (hit) return hit;
const { group: g, part } = M01_OF[group];
const files = await fetchFiles(g);
const f = files.find((x) => !part || x.file.includes(part)) ?? files[0];
fileCache.set(group, f.file);
return f.file;
}
/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */
export function renderInputPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
host.className = "m02-cfip";
const box = lg.boxes.find((b) => b.id === boxId);
if (!box || box.kind !== "input") {
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") }));
return;
}
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
const body = el("div", { className: "m02-cfip__body" });
// ① 갈래 — 그룹 여덟 + 변수
const kinds = el("div", {
className: "m02-cfip__chips",
attrs: { role: "tablist", "aria-label": t("Kind") },
children: [...GROUPS, "변수" as const].map((k) => {
const b = el("button", {
className: `m02-cfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`,
attrs: { type: "button", role: "tab" },
text: k === "변수" ? t("Variable") : t(k),
});
b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k)));
return b;
}),
});
const unit = el("p", {
className: "m02-cfip__unit",
text: `${t("BoxUnit")}: ${box.unit || "-"}`,
});
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
if (kind === "변수") renderVariables(body, lg, boxId, on);
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
else renderGroup(body, lg, box, kind, on);
}
/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */
function renderGroup(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const chosen = el("div", { className: "m02-cfip__chosen" });
const find = el("div", { className: "m02-cfip__find" });
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
drawChosen(chosen, lg, box, group, on);
void drawFind(find, lg, box, group, on);
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
function drawChosen(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const els = box.input?.elements ?? [];
const values = new Map<string, HTMLElement>();
host.replaceChildren(
el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }),
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-cfip__value", text: t("NoValue") });
values.set(e.ref, val);
return el("div", {
className: "m02-cfip__item",
children: [
el("span", { className: "m02-cfip__name", text: e.name }),
val,
el("span", { className: "m02-cfip__muted", text: e.unit || "-" }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
}),
],
});
})
: [el("p", { className: "m02-cfip__muted", text: t("NoneChosen") })]),
);
if (!els.length) return;
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }) as unknown as CfQuery);
void cfValues(items).then(
(answers) =>
answers.forEach((a) => {
const node = values.get(a.id);
if (node && a.ok && a.value !== null && a.value !== undefined)
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
}),
() => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」
);
}
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
async function drawFind(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): Promise<void> {
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-cfip__list" });
const count = el("span", { className: "m02-cfip__muted" });
const bar: HTMLElement[] = [];
let file = "";
let sub = "";
let detail = "";
let subs: SubInfo[] = [];
const all = { value: "", text: t("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("SubLabel"),
onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("DetailLabel"),
onChange: (v) => ((detail = v), void load()),
});
const fillDetail = (): void => {
const d = subs.find((s) => s.name === sub)?.details ?? [];
detailBox.root.hidden = !d.length;
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], "");
};
fillDetail();
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") }));
try {
const word = q.input.value.trim();
const [page, users] = await Promise.all([
fetchRows(file, 1, 40, word, false, sub, detail),
cfUserElements()
.then((u) =>
u.filter((x) => (x.group as string) === group || (x.group as string) === "사용자"),
)
.catch(() => []),
]);
if (mine !== seq) return;
const mineRows: M01Row[] = users
.filter((u) => !word || `${u.이름} ${u.규격}`.includes(word))
.map((u) => ({
키: u.key,
이름: u.이름,
규격: u.규격,
단위: u.단위,
값: u.값,
__mine: true,
}));
const rows = [...mineRows, ...(page.rows as M01Row[])];
count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length });
list.replaceChildren(
...(rows.length
? rows.map((r) => rowOf(r))
: [el("p", { className: "m02-cfip__muted", text: t("NoRow") })]),
);
} catch (e) {
if (mine === seq)
list.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) }));
}
};
const rowOf = (r: M01Row): HTMLElement => {
const fields = group === "기계" ? machineFields(r) : [];
const field = createSelectField({
options: fields.map((f) => ({ value: f, text: f })),
value: fields.includes("가격") ? "가격" : (fields[0] ?? ""),
compact: true,
ariaLabel: t("Field"),
});
const pick = (): CfLogic =>
addElement(lg, box.id, elementOf(group, r, field.select.value || "가격"));
const v = valueOf(group, r, field.select.value || "가격");
const unitText = group === "기계" ? "" : String(r["단위"] ?? "");
const b = el("button", {
className: "m02-cfip__row",
attrs: { type: "button" },
children: [
el("span", {
className: "m02-cfip__name",
children: [
el("strong", { text: String(r["이름"] ?? "") }),
el("span", { className: "m02-cfip__muted", text: ` ${r["규격"] ?? ""}` }),
...(r["__mine"] ? [el("span", { className: "m02-cfip__tag", text: t("Mine") })] : []),
],
}),
el("span", {
className: "m02-cfip__muted",
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
}),
],
});
b.addEventListener("click", () => on.change(pick()));
return el("div", {
className: "m02-cfip__rowbox",
children: [b, ...(fields.length ? [field.root] : [])],
});
};
try {
file = await fileOf(group);
subs = await fetchSubs({ file }).catch(() => []);
subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], "");
subBox.root.hidden = !subs.length;
bar.push(subBox.root, detailBox.root);
} catch (e) {
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) }));
return;
}
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(
el("div", { className: "m02-cfip__bar", children: [...bar, q.root] }),
count,
list,
);
void load();
void USER_PREFIX;
}
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
function newForm(
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): HTMLElement {
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true });
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
const go = createButton({
label: t("Make"),
onClick: () => {
const n =
value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, ""));
if (!name.input.value.trim()) return name.setError(t("NeedName"));
if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue"));
name.setError(undefined);
value.setError(undefined);
cfUserElementAdd({
group,
이름: name.input.value.trim(),
규격: spec.input.value.trim(),
단위: unit.input.value.trim(),
값: n,
}).then(
(made) => {
const el1 = elementOf(group, {
키: made.key,
이름: made.이름,
규격: made.규격,
단위: made.단위,
값: made.값,
});
const withUser: CfLogic = {
...lg,
userElements: [
...lg.userElements,
{
id: made.key,
group,
name: made.이름,
spec: made.규격,
unit: made.단위,
value: made.값,
},
],
};
on.change(addElement(withUser, box.id, el1));
},
(e) => showToast(plain(e), "error"),
);
},
});
const d = el("details", {
className: "m02-cfip__new",
children: [
el("summary", { text: t("NewHead") }),
name.root,
spec.root,
unit.root,
value.root,
go,
],
});
return d;
}
@@ -0,0 +1,283 @@
/* =============================================================================
* M02_CF_InputPanel_Build.ts
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_CF_InputPanel.ts`
* ========================================================================== */
import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_CF_Model";
/** 입력 상자가 고르는 그룹 — 화면 차례 */
export const GROUPS: CfGroup[] = [
"인력",
"재료",
"유가전력",
"기계",
"소요량",
"계수",
"환율",
"요율",
];
/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */
export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
인력: { group: "인력", part: "" },
재료: { group: "재료", part: "자재품목" },
유가전력: { group: "재료", part: "유가전력" },
기계: { group: "기계", part: "" },
소요량: { group: "소요량", part: "" },
계수: { group: "계수", part: "" },
환율: { group: "환율", part: "" },
요율: { group: "요율", part: "" },
};
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
export type M01Row = Record<string, unknown>;
/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */
export const USER_PREFIX = "UE-";
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
export const MACHINE_SEP = "#";
export const machineFields = (row: M01Row): string[] =>
row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : [];
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const dictUnit = (row: M01Row, field: string): string =>
row["단위"] && typeof row["단위"] === "object"
? text((row["단위"] as Record<string, unknown>)[field])
: "";
/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */
export function lowest(row: M01Row): number | null {
const found = ["물가자료", "유통물가", "물가정보", "거래가격"]
.map((k) => row[k])
.filter((v): v is number => typeof v === "number");
return found.length ? Math.min(...found) : null;
}
/** 줄 옆에 보일 값 — 모르면 null */
export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null {
if (group === "재료") return lowest(row);
if (group === "기계")
return typeof (row["값"] as Record<string, unknown> | undefined)?.[field] === "number"
? (row["값"] as Record<string, number>)[field]
: null;
if (group === "요율") {
const v = row["값"] as Record<string, unknown> | undefined;
return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null;
}
return typeof row["값"] === "number" ? row["값"] : null;
}
/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
const key = text(row["키"]);
const spec = text(row["규격"]);
if (key.startsWith(USER_PREFIX))
return {
ref: key,
name: [text(row["이름"]), spec].filter(Boolean).join(" "),
spec,
unit: text(row["단위"]) || "-",
};
const named = [text(row["이름"]), spec].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
if (group === "기계")
return {
ref: `${key}${MACHINE_SEP}${field}`,
name: `${named} · ${field}`,
spec,
unit: dictUnit(row, field) || "-",
};
if (group === "요율")
return {
ref: key,
name: text(row["이름"]),
spec: "",
unit: valueOf(group, row) === null ? "-" : "%",
};
return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" };
}
/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */
export function pillNames(box: CfBox): string[] {
const i = box.input;
if (!i) return [];
if (i.table) return ["표"];
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
}
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
if (ref.startsWith(USER_PREFIX)) return { group: "사용자", key: ref };
if (group === "기계") {
const [key, field] = ref.split(MACHINE_SEP);
return { group, key, field: field || "가격" };
}
return { group, key: ref };
}
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
export function unitOfInput(input: CfInput): string {
if (input.table) return "-";
const units = (
input.mode === "variable"
? input.variables.map((v) => v.unit)
: input.elements.map((e) => e.unit)
).map((u) => u || "-");
return units.length && units.every((u) => u === units[0]) ? units[0] : "-";
}
/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkVariable(v: CfVariable, others: CfVariable[]): string | null {
const name = v.name.trim();
if (!name) return "이름을 적어 주세요";
if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음";
if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상";
if (v.type === "number") {
if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함";
if (
typeof v.example === "number" &&
v.range &&
(v.example < v.range[0] || v.example > v.range[1])
)
return "예시값이 범위 밖";
if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자";
}
if (
v.type === "choice" &&
v.example !== null &&
!(v.choices ?? []).map(String).includes(String(v.example))
)
return "예시값이 선택 목록에 없음";
return null;
}
/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */
type Pills = (box: CfBox) => string[];
const defaultPills: Pills = pillNames;
const touch = (
lg: CfLogic,
id: string,
fn: (b: CfBox) => CfBox,
pills: Pills = defaultPills,
): CfLogic => {
const old = lg.boxes.find((b) => b.id === id);
if (!old) return lg;
const next = fn(old);
const boxes = lg.boxes.map((b) =>
b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b,
);
// 알약이 없어진 선은 지움
const keep = new Set(pills(next));
const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name));
return { ...lg, boxes, links };
};
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
if (kind === "변수")
return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } };
const same = cur.mode === "group" && cur.group === kind;
return {
...b,
input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] },
};
},
pills,
);
}
/** 요소 넣기 — 같은 요소는 한 번만 */
export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
return cur.elements.some((e) => e.ref === el.ref || e.name === el.name)
? b
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
},
pills,
);
}
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) },
}),
pills,
);
}
/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */
export function setTable(
lg: CfLogic,
id: string,
table: { key: string; row?: number } | null,
name?: string,
pills?: Pills,
): CfLogic {
const next = touch(
lg,
id,
(b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }),
pills,
);
return name
? {
...next,
boxes: next.boxes.map((b) =>
b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b,
),
}
: next;
}
/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */
export function putVariable(
lg: CfLogic,
id: string,
v: CfVariable,
oldName?: string,
pills?: Pills,
): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
const vars =
oldName === undefined
? [...cur.variables, v]
: cur.variables.map((x) => (x.name === oldName ? v : x));
return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } };
},
pills,
);
}
export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) },
}),
pills,
);
}
@@ -0,0 +1,323 @@
/* =============================================================================
* M02_CF_InputPanel_Table.ts
* 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나)
* `M02_CF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import type { CfGroup, CfLogic } from "./M02_CF_Model";
import { setTable } from "./M02_CF_InputPanel_Build";
const TEXT = {
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개", "{t} tables"],
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
Chosen: ["고른 표", "Chosen table"],
Whole: [
"표 통째 — 함수 상자가 조건에 맞는 줄을 찾음",
"Whole table — a function box looks it up",
],
OneRow: ["줄 하나 고정", "Fix one row"],
RowNo: ["몇 번째 줄", "Row number"],
Preview: ["미리 보기", "Preview"],
Notes: ["주(註)", "Notes"],
Remove: ["표 빼기", "Remove table"],
Change: ["다른 표 고르기", "Pick another table"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
credentials: "include",
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!res.ok) throw new Error(String(data.detail ?? res.statusText));
return data;
}
interface SectionItem {
section: string;
title: string;
chapter: string;
tables: number;
}
interface TableHead {
file: string;
키: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
interface RawTable {
키: string;
원문번호: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, unknown>[];
주?: string[];
}
const BOOKS = ["산림품셈", "건설품셈"];
const files = new Map<string, string>();
const tables = new Map<string, RawTable>();
/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */
export async function loadTable(group: CfGroup, key: string): Promise<RawTable> {
const hit = tables.get(key);
if (hit) return hit;
let file = files.get(key);
if (!file) {
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
(x) => x.키 === key,
);
if (!found) throw new Error(t("Failed"));
file = found.file;
}
const raw = (await get<{ table: RawTable }>("/table", { file, key })).table;
tables.set(key, raw);
return raw;
}
const cell = (v: unknown): string =>
Array.isArray(v)
? v.map((x) => x ?? "").join("~")
: v === null || v === undefined
? ""
: String(v);
/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */
function previewOf(tb: RawTable, row?: number): HTMLElement {
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1));
return el("div", {
className: "m02-cfip__preview",
children: [
el("table", {
children: [
el("tr", { children: cols.map((c) => el("th", { text: c })) }),
...body.map((r, i) =>
el("tr", {
className: i === row ? "is-hit" : "",
children: cols.map((c) => el("td", { text: cell(r[c]) })),
}),
),
],
}),
...(tb.줄.length > body.length
? [el("p", { className: "m02-cfip__muted", text: `… ${tb.줄.length} 줄` })]
: []),
...(tb.주?.length
? [
el("details", {
children: [
el("summary", { text: t("Notes") }),
...tb.주.map((n) => el("p", { text: n })),
],
}),
]
: []),
],
});
}
/** 소요량 · 계수 표 고르기 화면 */
export function renderTablePick(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const cur = box?.input?.table;
if (cur) return void showChosen(host, lg, boxId, group, cur, on);
let book = BOOKS[0];
const bookBox = createSelectField({
label: t("Book"),
options: BOOKS.map((b) => ({ value: b, text: b })),
value: book,
compact: true,
onChange: (v) => ((book = v), load()),
});
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-cfip__list" });
const tableList = el("div", { className: "m02-cfip__list" });
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book,
q: q.input.value.trim(),
limit: "60",
});
if (mine !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: "m02-cfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m02-cfip__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m02-cfip__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
try {
const heads = (
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: book,
size: "100",
})
).tables;
tableList.replaceChildren(
el("h5", { text: t("Tables") }),
...(heads.length
? heads.map((h) => {
const b = el("button", {
className: "m02-cfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m02-cfip__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => {
files.set(h.키, h.file);
void loadTable(group, h.키).then(
(tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)),
(e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"),
);
});
return b;
})
: [el("p", { className: "m02-cfip__muted", text: t("NoTable") })]),
);
} catch (e) {
showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(bookBox.root, q.root, list, tableList);
void load();
}
/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
cur: { key: string; row?: number },
on: { change(lg: CfLogic): void },
): void {
const body = el("div", { className: "m02-cfip__stack" });
host.replaceChildren(body);
void loadTable(group, cur.key).then(
(tb) => {
const whole = cur.row === undefined;
const modeWhole = createButton({
label: t("Whole"),
variant: whole ? "filled" : "ghost",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key })),
});
const modeOne = createButton({
label: t("OneRow"),
variant: whole ? "ghost" : "filled",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })),
});
const rowBox = createSelectField({
label: t("RowNo"),
options: tb.줄.map((r, i) => ({
value: String(i),
text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)]
.slice(0, 4)
.map((c) => cell(r[c]))
.join(" / ")}`,
})),
value: String(cur.row ?? 0),
compact: true,
onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })),
});
body.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }),
el("div", { className: "m02-cfip__chips", children: [modeWhole, modeOne] }),
...(whole ? [] : [rowBox.root]),
el("h5", { text: t("Preview") }),
previewOf(tb, cur.row),
el("div", {
className: "m02-cfip__chips",
children: [
createButton({
label: t("Change"),
variant: "ghost",
onClick: () => on.change(setTable(lg, boxId, null)),
}),
],
}),
);
},
(e) =>
body.replaceChildren(
el("p", {
className: "m02-cfip__muted",
text: e instanceof Error ? e.message : t("Failed"),
}),
),
);
}
@@ -0,0 +1,201 @@
/* =============================================================================
* M02_CF_InputPanel_Vars.ts
* 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값
* 변수마다 출력 알약 하나 · `M02_CF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { CfLogic, CfVariable } from "./M02_CF_Model";
import { checkVariable, putVariable, removeVariable } from "./M02_CF_InputPanel_Build";
const TEXT = {
List: ["변수 — 알약마다 하나", "Variables — one pill each"],
None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"],
Name: ["이름", "Name"],
Type: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
Unit: ["단위", "Unit"],
Min: ["범위 아래", "Range from"],
Max: ["범위 위", "Range to"],
Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"],
Example: ["예시값", "Example"],
Add: ["변수 넣기", "Add variable"],
Save: ["고치기 끝", "Save change"],
Edit: ["고치기", "Edit"],
Remove: ["삭제", "Remove"],
Cancel: ["취소", "Cancel"],
Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"],
Range: ["범위 {a}~{b}", "range {a}~{b}"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const numOf = (s: string): number | null =>
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
/** 변수 한 줄 글 */
export function variableLine(v: CfVariable): string {
const base = t("Line", {
n: v.name,
u: v.unit || "-",
e: v.example === null ? "—" : String(v.example),
});
const range =
v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : "";
const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : "";
return `${base}${range}${choices}`;
}
export function renderVariables(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const vars = box?.input?.variables ?? [];
let editing: CfVariable | null = null;
const form = (): HTMLElement => {
const cur = editing;
const name = createInputField({
type: "text",
label: t("Name"),
value: cur?.name ?? "",
placeholder: "예: 면적",
compact: true,
});
const type = createSelectField({
label: t("Type"),
options: [
{ value: "number", text: t("Number") },
{ value: "choice", text: t("Choice") },
{ value: "text", text: t("Text") },
],
value: cur?.type ?? "number",
compact: true,
onChange: () => paint(),
});
const unit = createInputField({
type: "text",
label: t("Unit"),
value: cur?.unit ?? "",
placeholder: "예: ㎡",
compact: true,
});
const lo = createInputField({
type: "text",
label: t("Min"),
value: cur?.range ? String(cur.range[0]) : "",
compact: true,
});
const hi = createInputField({
type: "text",
label: t("Max"),
value: cur?.range ? String(cur.range[1]) : "",
compact: true,
});
const choices = createInputField({
type: "text",
label: t("Choices"),
value: (cur?.choices ?? []).join(", "),
compact: true,
});
const example = createInputField({
type: "text",
label: t("Example"),
value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example),
compact: true,
});
const extra = el("div", { className: "m02-cfip__stack" });
const paint = (): void => {
const ty = type.select.value;
extra.replaceChildren(
...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []),
);
};
paint();
const submit = (): void => {
const ty = type.select.value as CfVariable["type"];
const ex = example.input.value.trim();
const v: CfVariable = {
name: name.input.value.trim(),
type: ty,
unit: unit.input.value.trim(),
example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex,
...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null
? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] }
: {}),
...(ty === "choice"
? {
choices: choices.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}
: {}),
};
const why = checkVariable(
v,
vars.filter((x) => x.name !== cur?.name),
);
name.setError(why ?? undefined);
if (why) return;
on.change(putVariable(lg, boxId, v, cur?.name));
};
const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit });
const cancel = cur
? createButton({
label: t("Cancel"),
variant: "ghost",
onClick: () => ((editing = null), draw()),
})
: null;
return el("div", {
className: "m02-cfip__form",
children: [
name.root,
type.root,
unit.root,
extra,
example.root,
el("div", { className: "m02-cfip__chips", children: cancel ? [ok, cancel] : [ok] }),
],
});
};
const draw = (): void => {
const list = vars.length
? vars.map((v) =>
el("div", {
className: "m02-cfip__item",
children: [
el("span", { text: variableLine(v) }),
createButton({
label: t("Edit"),
variant: "ghost",
size: "sm",
onClick: () => ((editing = v), draw()),
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeVariable(lg, boxId, v.name)),
}),
],
}),
)
: [el("p", { className: "m02-cfip__muted", text: t("None") })];
host.replaceChildren(el("h5", { text: t("List") }), ...list, form());
};
draw();
}
@@ -0,0 +1,206 @@
"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`M02_CF_InputPanel_Build.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 줄 · 상자는 이 시험 안에 둠.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M02_MasterTemplete" / "CostFlow" / "M02_CF_InputPanel_Build.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 box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((j) => {
if (j.op === "fn") return m[j.name](...j.args);
let lg = j.lg;
for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args);
return lg;
});
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)
def logic() -> dict:
plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0}
return {
"key": None,
"name": "견본",
"resultUnit": "원/㎡",
"userElements": [],
"boxes": [
{
"id": "b1",
"kind": "input",
"name": "입력",
**plain,
"input": {"mode": "variable", "elements": [], "variables": []},
},
{"id": "b2", "kind": "function", "name": "함수", **plain},
],
"links": [],
}
def call(name: str, *args):
return run([{"op": "fn", "name": name, "args": list(args)}])[0]
def edit(steps: list, lg: dict | None = None) -> dict:
return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0]
LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908}
LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068}
MAT = {
"키": "MT1",
"이름": "각형흄관",
"규격": "D=300mm",
"단위": "M",
"물가자료": 30000,
"물가정보": 29600,
}
MACHINE = {
"키": "EQ000001",
"이름": "불도저",
"규격": "7ton",
"단위": {"가격": "천원", "손료계수": "10^-7/시간"},
"값": {"가격": 76547, "손료계수": 1811},
}
RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}}
RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}}
def test_요소는_이름과_규격으로_알약_이름이_됨():
labor = call("elementOf", "인력", LABOR)
assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"}
got = call("elementOf", "재료", MAT)
assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M"
def test_기계는_값_칸을_골라_요소가_됨():
assert call("machineFields", MACHINE) == ["가격", "손료계수"]
got = call("elementOf", "기계", MACHINE, "손료계수")
assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간"
assert got["name"].endswith("· 손료계수")
assert call("queryOf", "기계", "EQ000001#손료계수") == {
"group": "기계",
"key": "EQ000001",
"field": "손료계수",
}
def test_값_읽기_재료는_낮은_값_요율은_비율():
assert call("valueOf", "재료", MAT) == 29600
assert call("valueOf", "요율", RATE_ONE) == 3.56
assert call("valueOf", "요율", RATE_BRACKET) is None
assert call("elementOf", "요율", RATE_ONE)["unit"] == "%"
assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-"
assert call("valueOf", "기계", MACHINE, "가격") == 76547
def test_사용자_요소는_사용자_그룹으로_물음():
assert call("queryOf", "인력", "UE-0001") == {"group": "사용자", "key": "UE-0001"}
row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000}
assert call("elementOf", "인력", row)["unit"] == "원/일"
assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%"
def test_상자_단위는_같으면_그_단위_여럿이면_하이픈():
one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]])
assert one["boxes"][0]["unit"] == "원/일"
two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one)
assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2
oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"}
assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-"
def test_같은_요소_이름은_한_번만():
el = call("elementOf", "인력", LABOR)
lg = edit(
[
["setKind", "b1", "인력"],
["addElement", "b1", el],
["addElement", "b1", el],
["addElement", "b1", {**el, "ref": "다른"}],
]
)
assert len(lg["boxes"][0]["input"]["elements"]) == 1
def test_요소를_빼면_그_알약에서_나간_선도_지움():
el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2)
base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]])
base["links"] = [
{"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}},
{"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}},
]
out = edit([["removeElement", "b1", "LB000033"]], base)
assert [x["id"] for x in out["links"]] == ["l2"]
assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"]
def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠():
el = call("elementOf", "인력", LABOR)
lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]])
assert lg["boxes"][0]["input"]["group"] == "재료"
assert lg["boxes"][0]["input"]["elements"] == []
t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]])
assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"}
assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-"
row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t)
assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3}
assert call("pillNames", row["boxes"][0]) == ["표"]
def test_변수_넣기_고치기_삭제_알약_이름():
v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]}
lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]])
box = lg["boxes"][0]
assert box["input"]["mode"] == "variable" and box["unit"] == "㎡"
assert call("pillNames", box) == ["면적"]
renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg)
assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m"
gone = edit([["removeVariable", "b1", "연장"]], renamed)
assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-"
def test_변수_검사는_쉬운_말():
ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]}
assert call("checkVariable", ok, []) is None
assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요"
assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음"
assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함"
assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖"
many = {**ok, "type": "choice", "choices": ["가"]}
assert call("checkVariable", many, []) == "선택 목록은 둘 이상"
bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"}
assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음"