Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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,455 @@
|
||||
/* =============================================================================
|
||||
* 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 } from "./M02_CF_Api";
|
||||
import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_CF_Model";
|
||||
import {
|
||||
GROUPS,
|
||||
M01_OF,
|
||||
MACHINE_SEP,
|
||||
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";
|
||||
|
||||
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
|
||||
interface Answer {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | string | null;
|
||||
}
|
||||
interface UserEl {
|
||||
id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
}
|
||||
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
|
||||
const apiUsers = cfUserElements as unknown as () => Promise<UserEl[]>;
|
||||
const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise<UserEl>;
|
||||
|
||||
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) }));
|
||||
void apiValues(items, lg.userElements).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);
|
||||
}),
|
||||
// Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로)
|
||||
() =>
|
||||
els.forEach(
|
||||
(e) =>
|
||||
void m01Value(group, e).then((v) => {
|
||||
const node = values.get(e.ref);
|
||||
if (node && v !== null) node.textContent = fmt(v);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */
|
||||
async function m01Value(group: CfGroup, e: CfElementRef): Promise<number | null> {
|
||||
if (e.ref.startsWith(USER_PREFIX)) return null;
|
||||
try {
|
||||
const [key, field] = e.ref.split(MACHINE_SEP);
|
||||
const page = await fetchRows(
|
||||
await fileOf(group),
|
||||
1,
|
||||
30,
|
||||
e.name.split(" · ")[0].split(" ")[0],
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
);
|
||||
const row = (page.rows as M01Row[]).find((r) => r["키"] === key);
|
||||
return row ? valueOf(group, row, field || "가격") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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),
|
||||
apiUsers().catch((): UserEl[] => []),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
const known = new Map<string, UserEl>();
|
||||
for (const u of [...lg.userElements, ...users])
|
||||
if (u.group === group) known.set(u.id, u as UserEl);
|
||||
const mineRows: M01Row[] = [...known.values()]
|
||||
.filter((u) => !word || `${u.name} ${u.spec}`.includes(word))
|
||||
.map((u) => ({
|
||||
키: u.id,
|
||||
이름: u.name,
|
||||
규격: u.spec,
|
||||
단위: u.unit,
|
||||
값: u.value,
|
||||
__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);
|
||||
apiAdd({
|
||||
group,
|
||||
name: name.input.value.trim(),
|
||||
spec: spec.input.value.trim(),
|
||||
unit: unit.input.value.trim(),
|
||||
value: n,
|
||||
}).then(
|
||||
(made) => {
|
||||
const el1 = elementOf(group, {
|
||||
키: made.id,
|
||||
이름: made.name,
|
||||
규격: made.spec,
|
||||
단위: made.unit,
|
||||
값: made.value,
|
||||
});
|
||||
const withUser: CfLogic = {
|
||||
...lg,
|
||||
userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }],
|
||||
};
|
||||
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,284 @@
|
||||
/* =============================================================================
|
||||
* 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 tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
|
||||
const named = [text(row["이름"]), tail].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 (group === "기계" && !ref.startsWith(USER_PREFIX)) {
|
||||
const [key, field] = ref.split(MACHINE_SEP);
|
||||
return { group, ref: key, field: field || "가격" };
|
||||
}
|
||||
return { group, 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,221 @@
|
||||
"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`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": "기계",
|
||||
"ref": "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_사용자_요소는_그_그룹의_ref_로_물음():
|
||||
assert call("queryOf", "인력", "UE-0001") == {"group": "인력", "ref": "UE-0001"}
|
||||
assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"}
|
||||
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, []) == "예시값이 선택 목록에 없음"
|
||||
|
||||
|
||||
def test_환율_유가전력_알약_이름에_날짜가_안_들어감():
|
||||
fx = {"키": "FX1", "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9}
|
||||
assert call("elementOf", "환율", fx)["name"] == "USD"
|
||||
oil = {
|
||||
"키": "MO1",
|
||||
"이름": "휘발유",
|
||||
"규격": "2026-08-14",
|
||||
"상세구분": "서울",
|
||||
"단위": "원/L",
|
||||
"값": 1900,
|
||||
}
|
||||
assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울"
|
||||
Reference in New Issue
Block a user