Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -45,6 +45,8 @@ import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry";
|
||||
import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry";
|
||||
// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry";
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry";
|
||||
import "./M01_MasterData_UI_Logic_Style.css";
|
||||
|
||||
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
|
||||
@@ -410,6 +412,8 @@ export async function mountM01LogicLab(
|
||||
createLogicBuilderButton(),
|
||||
// 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createSheetLogicButton(),
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createCostFlowButton(),
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
saveButton,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */
|
||||
|
||||
.m02-cf {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-cf:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-cf__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 위 띠 — 밀어도 위에 붙어 있음 */
|
||||
.m02-cf__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-12) var(--spacing-24);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-cf__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m02-cf__trial {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-cf__strip {
|
||||
display: flex;
|
||||
flex: 1 1 480px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-cf__name {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
|
||||
.m02-cf__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m02-cf__body {
|
||||
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
|
||||
}
|
||||
|
||||
.m02-cf__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 */
|
||||
.m02-cf__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.m02-cf__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-cf__start-col h3 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */
|
||||
.m02-cf__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m02-cf__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m02-cf__part h3 {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-cf__part-body {
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-cf__diagram .m02-cf__part-body {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.m02-cf__lower {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-cf__box .m02-cf__part-body {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.m02-cf__box-slots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-cf__slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-cf__slot h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.m02-cf__lower,
|
||||
.m02-cf__start,
|
||||
.m02-cf__box-slots {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.m02-cf__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-cf__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-cf__item:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m02-cf__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-cf__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/* =============================================================================
|
||||
* M02_CF_Entry.ts
|
||||
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
|
||||
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게.
|
||||
* 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음.
|
||||
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfListItem } from "./M02_CF_Api";
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
import "./M02_CF_Entry.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
|
||||
Title: ["단가산출 다이어그램", "Cost diagram"],
|
||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Start_New: ["새로 만들기", "New"],
|
||||
Start_Mine: ["내 로직", "My logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Samples: ["견본", "Samples"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
Undo: ["실행 취소", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
Diagram: ["다이어그램", "Diagram"],
|
||||
Box: ["고른 상자", "Chosen box"],
|
||||
Panel_Input: ["입력", "Input"],
|
||||
Panel_Function: ["함수", "Function"],
|
||||
Panel_Output: ["출력", "Output"],
|
||||
Summary: ["원가", "Cost"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
const te = (key: TextKey): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text });
|
||||
|
||||
/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */
|
||||
const blankLogic = (name: string, unit: string): Promise<CfLogic | null> =>
|
||||
import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
|
||||
const loadList = (): Promise<CfListItem[]> =>
|
||||
import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []);
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
try {
|
||||
for (const x of await fetchLogics()) {
|
||||
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
|
||||
}
|
||||
} catch {
|
||||
/* 못 받으면 기본 하나 */
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
|
||||
export function createCostFlowButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow });
|
||||
}
|
||||
|
||||
function openCostFlow(): void {
|
||||
/** 고치는 화면이 열려 있을 때만 */
|
||||
let paper: { dirty(): boolean } | null = null;
|
||||
let units: string[] | null = null;
|
||||
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
const body = el("main", { className: "m02-cf__body" });
|
||||
const strip = el("div", { className: "m02-cf__strip" });
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})(),
|
||||
});
|
||||
const page = el("div", {
|
||||
className: "m02-cf",
|
||||
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
|
||||
children: [
|
||||
el("header", {
|
||||
className: "m02-cf__head",
|
||||
children: [
|
||||
el("h2", { className: "m02-cf__title", text: te("Title") }),
|
||||
el("span", { className: "m02-cf__trial", text: te("Trial") }),
|
||||
strip,
|
||||
close,
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
const shut = (): void => {
|
||||
page.remove();
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
page.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape" || ev.defaultPrevented) return;
|
||||
void (async () => {
|
||||
if (await mayLeave()) shut();
|
||||
})();
|
||||
});
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: CfListItem): HTMLElement => {
|
||||
const row = el("button", {
|
||||
className: "m02-cf__item",
|
||||
attrs: { type: "button", title: item.saved },
|
||||
children: [
|
||||
el("span", { className: "m02-cf__item-name", text: item.name }),
|
||||
el("span", { className: "m02-cf__item-unit", text: item.resultUnit }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => showToast(te("NotYet"), "info"));
|
||||
return row;
|
||||
};
|
||||
|
||||
const showStart = (): void => {
|
||||
paper = null;
|
||||
strip.replaceChildren();
|
||||
const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
|
||||
void loadList().then((all) => {
|
||||
const mine = all.filter((x) => !x.sample);
|
||||
const samples = all.filter((x) => x.sample);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
sampleHost.replaceChildren(
|
||||
...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]),
|
||||
);
|
||||
});
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-cf__start",
|
||||
children: [
|
||||
el("section", {
|
||||
className: "m02-cf__start-col",
|
||||
children: [
|
||||
bigButton(te("Start_New"), () => void openNew()),
|
||||
el("h3", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m02-cf__start-col",
|
||||
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 고치는 화면 ---------- */
|
||||
|
||||
const openNew = async (): Promise<void> => {
|
||||
units ??= await unitChoices();
|
||||
const lg = await blankLogic("", units[0]);
|
||||
showPaper(lg, units);
|
||||
};
|
||||
|
||||
const showPaper = (lg: CfLogic | null, choices: string[]): void => {
|
||||
let dirty = false;
|
||||
paper = { dirty: () => dirty };
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
};
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: lg?.name ?? "",
|
||||
onInput: touched,
|
||||
});
|
||||
name.root.classList.add("m02-cf__name");
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg?.resultUnit ?? choices[0],
|
||||
onChange: touched,
|
||||
});
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
strip.replaceChildren(
|
||||
name.root,
|
||||
unit.root,
|
||||
unsaved,
|
||||
el("div", {
|
||||
className: "m02-cf__actions",
|
||||
children: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-cf__layout",
|
||||
children: [
|
||||
part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]),
|
||||
el("div", {
|
||||
className: "m02-cf__lower",
|
||||
children: [
|
||||
part(te("Box"), "m02-cf__box", [
|
||||
el("div", {
|
||||
className: "m02-cf__box-slots",
|
||||
children: [
|
||||
slot(te("Panel_Input")),
|
||||
slot(te("Panel_Function")),
|
||||
slot(te("Panel_Output")),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
name.input.focus();
|
||||
};
|
||||
|
||||
showStart();
|
||||
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
|
||||
document.body.append(page);
|
||||
page.tabIndex = -1;
|
||||
page.focus();
|
||||
}
|
||||
|
||||
/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */
|
||||
function part(title: string, className: string, children: HTMLElement[]): HTMLElement {
|
||||
return el("section", {
|
||||
className: `m02-cf__part ${className}`,
|
||||
children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })],
|
||||
});
|
||||
}
|
||||
|
||||
/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */
|
||||
function slot(title: string): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-cf__slot",
|
||||
children: [el("h4", { text: title }), muted(te("NotYet"))],
|
||||
});
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m02-cf__big");
|
||||
return button;
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
* M02_CF_Eval.ts
|
||||
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
|
||||
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
|
||||
* 엔진을 정적으로 가져옴 — 화면(Entry)은 CostFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfLogic } from "./M02_CF_Model";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_CF_Model";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
|
||||
export type CfValue = number | string | boolean | null | (number | string | null)[][];
|
||||
@@ -12,33 +17,188 @@ export type CfValue = number | string | boolean | null | (number | string | null
|
||||
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
|
||||
export type CfValues = Record<string, Record<string, CfValue>>;
|
||||
|
||||
type Scalar = number | string | boolean | null;
|
||||
|
||||
export interface CfEvalResult {
|
||||
/** 상자 id → 내보내는 알약 이름 → 값 */
|
||||
ports: Record<string, Record<string, CfValue>>;
|
||||
/** 출력 상자 id → 값 */
|
||||
outputs: Record<string, number | string | boolean | null>;
|
||||
outputs: Record<string, Scalar>;
|
||||
cost: {
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
|
||||
값들: Record<string, number | string | boolean | null>;
|
||||
값들: Record<string, Scalar>;
|
||||
계: number;
|
||||
};
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
const TEXT = {
|
||||
/** {box} {cell} {err} */
|
||||
CellError: [
|
||||
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
|
||||
"「{box}」 cell {cell} can't be worked out — {err}",
|
||||
],
|
||||
Cycle: ["돌고 도는 참조", "circular reference"],
|
||||
/** {box} */
|
||||
Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"],
|
||||
NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"],
|
||||
/** {box} {pill} */
|
||||
EmptyIn: [
|
||||
"「{box}」 의 받는 알약 「{pill}」 에 선이 없음",
|
||||
"Pill 「{pill}」 of 「{box}」 has no line",
|
||||
],
|
||||
NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"],
|
||||
NoOutput: ["출력 상자가 없음", "There is no output box"],
|
||||
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
Object.entries(fill).reduce(
|
||||
(s, [k, v]) => s.replace(`{${k}}`, v),
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
|
||||
);
|
||||
|
||||
/* ── 차례 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */
|
||||
function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } {
|
||||
const funcs = lg.boxes.filter((b) => b.kind === "function");
|
||||
const done: CfBox[] = [];
|
||||
const left = new Set(funcs.map((f) => f.id));
|
||||
for (let moved = true; moved && left.size;) {
|
||||
moved = false;
|
||||
for (const f of funcs) {
|
||||
if (!left.has(f.id)) continue;
|
||||
const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box));
|
||||
if (waits) continue;
|
||||
done.push(f);
|
||||
left.delete(f.id);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) };
|
||||
}
|
||||
|
||||
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function putValue(칸: Record<string, Cell>, cell: string, value: Scalar): void {
|
||||
const style = 칸[cell]?.서식;
|
||||
if (value === null) {
|
||||
if (style) 칸[cell] = { 서식: style };
|
||||
else delete 칸[cell];
|
||||
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
|
||||
}
|
||||
|
||||
/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */
|
||||
function put(칸: Record<string, Cell>, cell: string, value: CfValue): void {
|
||||
const at = parseA1(cell);
|
||||
if (!at) return;
|
||||
if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value);
|
||||
value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x)));
|
||||
}
|
||||
|
||||
const fromCalc = (v: CalcValue | undefined): Scalar => {
|
||||
if (!v) return null;
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
};
|
||||
|
||||
/** 화면 계산 */
|
||||
export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult {
|
||||
return notReady();
|
||||
/* ── 계산 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
|
||||
function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, CfValue> {
|
||||
const given = values[box.id] ?? {};
|
||||
const pills = portsOut(box);
|
||||
const out: Record<string, CfValue> = {};
|
||||
const inp = box.input;
|
||||
if (!inp) return out;
|
||||
// portsOut 차례 = 요소 · 표 · 변수
|
||||
const sources: CfValue[] = [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
|
||||
: []),
|
||||
...(inp.mode === "group" && inp.table ? [null] : []),
|
||||
...inp.variables.map((v) => v.example),
|
||||
];
|
||||
pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] {
|
||||
return notReady();
|
||||
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
|
||||
function runFunc(
|
||||
box: CfBox,
|
||||
incoming: Record<string, CfValue>,
|
||||
given: Record<string, CfValue>,
|
||||
errors: string[],
|
||||
): Scalar {
|
||||
const func = box.func!;
|
||||
const sheet: Sheet = structuredClone(func.sheet);
|
||||
for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null);
|
||||
for (const v of func.variables)
|
||||
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
|
||||
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "CF", 서식: [{}], 시트: [sheet] });
|
||||
for (const [cell, v] of Object.entries(values[sheet.id] ?? {}))
|
||||
if ("오류" in v) {
|
||||
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
|
||||
errors.push(tl("CellError", { box: box.name, cell, err }));
|
||||
}
|
||||
const at = func.output.cell.toUpperCase();
|
||||
const c = sheet.칸[at];
|
||||
if (!c) return null;
|
||||
return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
|
||||
}
|
||||
|
||||
/** 화면 계산 */
|
||||
export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult {
|
||||
const errors: string[] = [];
|
||||
const ports: CfEvalResult["ports"] = {};
|
||||
for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values);
|
||||
const into = (box: string): Record<string, CfValue> =>
|
||||
Object.fromEntries(
|
||||
lg.links
|
||||
.filter((l) => l.to.box === box)
|
||||
.map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]),
|
||||
);
|
||||
const { funcs, loop } = order(lg);
|
||||
for (const f of loop) errors.push(tl("Loop", { box: f.name }));
|
||||
for (const f of funcs)
|
||||
ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) };
|
||||
const outputs: CfEvalResult["outputs"] = {};
|
||||
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "output")) {
|
||||
const v = into(b.id)[OUTPUT_PORT] ?? null;
|
||||
const scalar: Scalar = Array.isArray(v) ? null : v;
|
||||
outputs[b.id] = scalar;
|
||||
const kind = b.output?.cost ?? "값";
|
||||
if (kind === "값") cost.값들[b.name] = scalar;
|
||||
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
|
||||
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
|
||||
}
|
||||
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
|
||||
return { ports, outputs, cost, errors };
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
|
||||
const out: string[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
|
||||
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
|
||||
out.push(tl("NoCell", { box: b.name }));
|
||||
}
|
||||
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
|
||||
const ev = evalLogic(lg, values);
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "input"))
|
||||
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
|
||||
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
|
||||
out.push(tl("NoValue", { box: b.name, pill }));
|
||||
return [...out, ...ev.errors];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export type CfKind = "input" | "function" | "output";
|
||||
@@ -105,60 +106,190 @@ export interface CfPill {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
const TEXT = {
|
||||
Input: ["입력", "Input"],
|
||||
Func: ["함수", "Function"],
|
||||
Output: ["출력", "Output"],
|
||||
Result: ["결과", "Result"],
|
||||
Value: ["값", "Value"],
|
||||
NoPill: ["알약이 없음", "No such pill"],
|
||||
FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"],
|
||||
ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"],
|
||||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||||
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
|
||||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/** 종류별 기본 색 */
|
||||
export const BOX_COLOR: Record<CfKind, string> = {
|
||||
input: "#2f80ed",
|
||||
function: "#7b4fd6",
|
||||
output: "#27ae60",
|
||||
};
|
||||
|
||||
export function newLogic(_name?: string, _resultUnit?: string): CfLogic {
|
||||
return notReady();
|
||||
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
|
||||
export const OUTPUT_PORT = "값";
|
||||
export const TABLE_PORT = "표";
|
||||
|
||||
const clone = <T>(v: T): T => structuredClone(v);
|
||||
|
||||
/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */
|
||||
function nextId(prefix: string, ids: string[]): string {
|
||||
const nums = ids
|
||||
.filter((x) => x.startsWith(prefix))
|
||||
.map((x) => Number(x.slice(prefix.length)))
|
||||
.filter((n) => Number.isInteger(n));
|
||||
return `${prefix}${Math.max(0, ...nums) + 1}`;
|
||||
}
|
||||
|
||||
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */
|
||||
/** 같은 이름이면 「 2」 「 3」 */
|
||||
function uniqueName(used: string[], base: string): string {
|
||||
if (!used.includes(base)) return base;
|
||||
for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`;
|
||||
}
|
||||
|
||||
const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
|
||||
|
||||
/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */
|
||||
export function updateBox(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
patch: Partial<Omit<CfBox, "id" | "kind">>,
|
||||
): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
|
||||
|
||||
export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic {
|
||||
return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] };
|
||||
}
|
||||
|
||||
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */
|
||||
export function addBox(
|
||||
_lg: CfLogic,
|
||||
_kind: CfKind,
|
||||
_x: number,
|
||||
_y: number,
|
||||
lg: CfLogic,
|
||||
kind: CfKind,
|
||||
x: number,
|
||||
y: number,
|
||||
): { lg: CfLogic; box: CfBox } {
|
||||
return notReady();
|
||||
const id = nextId(
|
||||
"b",
|
||||
lg.boxes.map((b) => b.id),
|
||||
);
|
||||
const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output");
|
||||
const name = uniqueName(
|
||||
lg.boxes.map((b) => b.name),
|
||||
base,
|
||||
);
|
||||
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
|
||||
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
|
||||
if (kind === "function")
|
||||
box.func = {
|
||||
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } },
|
||||
inputs: [],
|
||||
variables: [],
|
||||
output: { cell: "B1", name: tl("Result"), unit: "-" },
|
||||
};
|
||||
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
|
||||
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
|
||||
}
|
||||
|
||||
/** 상자 지우기 — 이어진 선도 지움 */
|
||||
export function removeBox(_lg: CfLogic, _id: string): CfLogic {
|
||||
return notReady();
|
||||
export function removeBox(lg: CfLogic, id: string): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.filter((b) => b.id !== id),
|
||||
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic {
|
||||
return notReady();
|
||||
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
|
||||
return updateBox(lg, id, { x, y });
|
||||
}
|
||||
|
||||
export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic {
|
||||
return notReady();
|
||||
export function setColor(lg: CfLogic, id: string, color: string): CfLogic {
|
||||
return updateBox(lg, id, { color });
|
||||
}
|
||||
|
||||
export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic {
|
||||
return notReady();
|
||||
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
|
||||
export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
|
||||
const box = boxOf(lg, id);
|
||||
if (!box) return lg;
|
||||
const u = unit.trim() || "-";
|
||||
const patch: Partial<CfBox> = { unit: u };
|
||||
if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } };
|
||||
if (box.output) patch.output = { ...box.output, unit: u };
|
||||
return updateBox(lg, id, patch);
|
||||
}
|
||||
|
||||
/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */
|
||||
export function portsOut(_box: CfBox): CfPill[] {
|
||||
return notReady();
|
||||
/* ── 알약 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
|
||||
* 함수 = func.output.name 하나 · 출력 = 없음 */
|
||||
export function portsOut(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
|
||||
if (box.kind !== "input" || !box.input) return [];
|
||||
const inp = box.input;
|
||||
return [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
|
||||
: []),
|
||||
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
|
||||
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
|
||||
];
|
||||
}
|
||||
|
||||
/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */
|
||||
export function portsIn(_box: CfBox): CfPill[] {
|
||||
return notReady();
|
||||
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
|
||||
export function portsIn(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
|
||||
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */
|
||||
export function link(
|
||||
_lg: CfLogic,
|
||||
_from: CfPort,
|
||||
_to: CfPort,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
return notReady();
|
||||
/* ── 선 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** from 상자에서 선을 따라 to 상자에 닿나 */
|
||||
export function reaches(lg: CfLogic, from: string, to: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (id === to) return true;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
|
||||
};
|
||||
return walk(from);
|
||||
}
|
||||
|
||||
export function unlink(_lg: CfLogic, _id: string): CfLogic {
|
||||
return notReady();
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */
|
||||
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
|
||||
const a = boxOf(lg, from.box);
|
||||
const b = boxOf(lg, to.box);
|
||||
if (!a || !b) return { lg, why: tl("NoPill") };
|
||||
if (a.kind === "output") return { lg, why: tl("FromOutput") };
|
||||
if (b.kind === "input") return { lg, why: tl("ToInput") };
|
||||
if (a.id === b.id) return { lg, why: tl("Self") };
|
||||
if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name))
|
||||
return { lg, why: tl("NoPill") };
|
||||
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
|
||||
if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
|
||||
return { lg, why: tl("Taken") };
|
||||
const id = nextId(
|
||||
"l",
|
||||
lg.links.map((l) => l.id),
|
||||
);
|
||||
return {
|
||||
lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] },
|
||||
why: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function unlink(lg: CfLogic, id: string): CfLogic {
|
||||
return { ...lg, links: lg.links.filter((l) => l.id !== id) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_CF_Model.ts` · `M02_CF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
|
||||
|
||||
· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
|
||||
· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사
|
||||
· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CF = ROOT / "M02_MasterTemplete" / "CostFlow"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
LOADER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = process.argv[1];
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full).exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const mod = { exports: {} };
|
||||
cache.set(full, mod);
|
||||
const req = (r) => {
|
||||
if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 };
|
||||
if (r.endsWith(".css")) return {};
|
||||
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
|
||||
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`));
|
||||
return require(r);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, req);
|
||||
return mod.exports;
|
||||
}
|
||||
const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Model.ts"));
|
||||
const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Eval.ts"));
|
||||
const out = (x) => console.log(JSON.stringify(x));
|
||||
const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y);
|
||||
const join = (lg, a, ap, b, bp) => {
|
||||
const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp });
|
||||
if (r.why) throw new Error(r.why);
|
||||
return r.lg;
|
||||
};
|
||||
/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */
|
||||
const func = (lg, box, cells, inputs, output, variables = []) =>
|
||||
M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables,
|
||||
output: { ...box.func.output, ...output } } });
|
||||
"""
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "-e", LOADER + body, str(ROOT)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비
|
||||
ONE = r"""
|
||||
let lg = M.newLogic("견본 하나", "원/㎡");
|
||||
let r = add(lg, "input"); lg = r.lg; let man = r.box;
|
||||
lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력",
|
||||
elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } });
|
||||
r = add(lg, "input"); lg = r.lg; let area = r.box;
|
||||
lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [],
|
||||
variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } });
|
||||
r = add(lg, "function"); lg = r.lg; let f = r.box;
|
||||
lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } },
|
||||
[{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" },
|
||||
[{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]);
|
||||
r = add(lg, "output"); lg = r.lg; let o = r.box;
|
||||
lg = join(lg, man, "석공", f, "노임");
|
||||
lg = join(lg, area, "면적", f, "면적");
|
||||
lg = join(lg, f, "노무비", o, "값");
|
||||
const V = { [man.id]: { 석공: 268908 } };
|
||||
"""
|
||||
|
||||
# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬)
|
||||
TWO = r"""
|
||||
let lg = M.newLogic("견본 둘");
|
||||
let r = add(lg, "input"); lg = r.lg; const inp = r.box;
|
||||
lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력",
|
||||
elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }],
|
||||
variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } });
|
||||
const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box;
|
||||
lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; };
|
||||
const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]);
|
||||
const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]);
|
||||
const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]);
|
||||
const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]);
|
||||
lg = join(lg, inp, "면적", f1, "가");
|
||||
lg = join(lg, inp, "보통인부", f2, "가");
|
||||
lg = join(lg, f1, "결과", f3, "가");
|
||||
lg = join(lg, f2, "결과", f3, "나");
|
||||
lg = join(lg, f3, "결과", f4, "가");
|
||||
r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } });
|
||||
r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } });
|
||||
r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } });
|
||||
lg = join(lg, f4, "결과", oExp, "값");
|
||||
lg = join(lg, f1, "결과", oVal, "값");
|
||||
lg = join(lg, inp, "면적", oMat, "값");
|
||||
const V = { [inp.id]: { 보통인부: 172068 } };
|
||||
"""
|
||||
|
||||
|
||||
def test_견본하나_입력_변수_함수_출력_노무비() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);"
|
||||
)
|
||||
ev, check, boxes = got
|
||||
assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908
|
||||
assert ev["outputs"] == {"b4": 537816}
|
||||
assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816}
|
||||
assert ev["errors"] == [] and check == []
|
||||
assert boxes == [
|
||||
["b1", "input", "입력", "#2f80ed", "-"],
|
||||
["b2", "input", "입력 2", "#2f80ed", "-"],
|
||||
["b3", "function", "함수", "#7b4fd6", "-"],
|
||||
["b4", "output", "출력", "#27ae60", "-"],
|
||||
]
|
||||
|
||||
|
||||
def test_변수값_바꾸면_값이_바뀜() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);"
|
||||
)
|
||||
assert got == 0.1 * 20 * 268908
|
||||
|
||||
|
||||
def test_견본둘_직렬_병렬_원가_합() -> None:
|
||||
got = _run(
|
||||
TWO
|
||||
+ "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);"
|
||||
)
|
||||
ports, cost, errors, check = got
|
||||
f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5"))
|
||||
assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938)
|
||||
assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943}
|
||||
assert errors == [] and check == []
|
||||
|
||||
|
||||
def test_선_규칙() -> None:
|
||||
got = _run(
|
||||
TWO
|
||||
+ "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n"
|
||||
"out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적'),"
|
||||
" t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값'),"
|
||||
" M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);"
|
||||
)
|
||||
assert got == [
|
||||
"이으면 돌고 도는 선이 됨",
|
||||
"이으면 돌고 도는 선이 됨",
|
||||
"출력 상자에서는 선이 나가지 않음",
|
||||
"입력 상자로는 선이 들어가지 않음",
|
||||
"같은 상자끼리는 못 이음",
|
||||
"이미 선이 들어온 자리",
|
||||
"알약이 없음",
|
||||
"이미 선이 들어온 자리",
|
||||
True,
|
||||
]
|
||||
|
||||
|
||||
def test_알약_출력은_하나() -> None:
|
||||
got = _run(
|
||||
TWO
|
||||
+ "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n"
|
||||
"out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3]),"
|
||||
" M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);"
|
||||
)
|
||||
in_out, in_in, f_out, f_in, o_out, o_in = got
|
||||
assert in_out == [
|
||||
{"name": "보통인부", "unit": "인"},
|
||||
{"name": "표", "unit": "-"},
|
||||
{"name": "면적", "unit": "㎡"},
|
||||
]
|
||||
assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}]
|
||||
assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}]
|
||||
assert o_out == [] and o_in == [{"name": "값", "unit": "원"}]
|
||||
|
||||
|
||||
def test_선_끊기_상자_지우기_검사() -> None:
|
||||
got = _run(
|
||||
TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n"
|
||||
"const a = M.unlink(lg, cut);\n"
|
||||
"const b = M.removeBox(lg, f3.id);\n"
|
||||
"const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n"
|
||||
"out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length,"
|
||||
" E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);"
|
||||
)
|
||||
f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got
|
||||
assert f3_after == {"결과": 10}
|
||||
assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"]
|
||||
assert (n_b, n_all) == (5, 8)
|
||||
assert check_b == [
|
||||
"「함수 4」 의 받는 알약 「가」 에 선이 없음",
|
||||
]
|
||||
assert check_c == ["출력 상자가 없음"]
|
||||
assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"]
|
||||
|
||||
|
||||
def test_오류칸_순환칸_숫자아닌_출력() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
|
||||
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n"
|
||||
"const a = E.evalLogic(lg, V);\n"
|
||||
"lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet,"
|
||||
" 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n"
|
||||
"const b = E.evalLogic(lg, V);\n"
|
||||
"out([a.errors, a.cost.계, b.errors]);"
|
||||
)
|
||||
a_err, total, b_err = got
|
||||
assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"]
|
||||
assert total == 0
|
||||
assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err
|
||||
|
||||
|
||||
def test_자리_색_단위() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n"
|
||||
"g = M.setUnit(g, o.id, ' ');\n"
|
||||
"const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n"
|
||||
"out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);"
|
||||
)
|
||||
assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0]
|
||||
|
||||
|
||||
def test_사용자_요소_값() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n"
|
||||
"lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n"
|
||||
"out(E.evalLogic(lg, {}).cost.노무비);"
|
||||
)
|
||||
assert got == 0.2 * 10 * 300000
|
||||
|
||||
|
||||
def test_화면_글에_금지_낱말_없음() -> None:
|
||||
for name in ("M02_CF_Model.ts", "M02_CF_Eval.ts"):
|
||||
src = (CF / name).read_text(encoding="utf-8")
|
||||
block = src[src.index("const TEXT = {") : src.index("} as const;")]
|
||||
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
|
||||
assert korean
|
||||
for text in korean:
|
||||
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
|
||||
assert bad not in text, (name, bad, text)
|
||||
|
||||
|
||||
def test_알약_이름_규칙() -> None:
|
||||
got = _run(
|
||||
"let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n"
|
||||
"lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료',"
|
||||
" elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 },"
|
||||
" variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n"
|
||||
"out(M.portsOut(lg.boxes[0]).map(p => p.name));"
|
||||
)
|
||||
assert got == ["시멘트", "표", "길이"]
|
||||
Reference in New Issue
Block a user