Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1
This commit is contained in:
@@ -17,6 +17,7 @@ import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
|
||||
import { nameMachines, optionText } from "./formula_doc_options";
|
||||
import "./calc_sheet.css";
|
||||
|
||||
export interface SheetLine {
|
||||
@@ -162,16 +163,17 @@ const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
|
||||
export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
control = createSelectField({
|
||||
options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({
|
||||
value: o,
|
||||
text: o,
|
||||
})),
|
||||
const list = [...(value ? [] : [""]), ...spec.고르기.map(String)];
|
||||
const items = () => list.map((o) => ({ value: o, text: optionText(o) })); // 기계 번호 = 이름(요구 692-2)
|
||||
const f = createSelectField({
|
||||
options: items(),
|
||||
value,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: onInput,
|
||||
}).root;
|
||||
});
|
||||
nameMachines(list, () => f.setOptions(items(), f.select.value));
|
||||
control = f.root;
|
||||
} else {
|
||||
const box = createInputField({ value, compact: true });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
|
||||
@@ -2,10 +2,29 @@
|
||||
* formula_doc_options.ts
|
||||
* 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나
|
||||
* 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만
|
||||
* 목록이 모두 기계 원문번호(`0101-0007`)면 글 = 「이름 규격 (원문번호)」(요구 692-2 · 값은 그대로 · 이름은 한 번 받아 둠)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { cfMachineNames } from "../M02_MasterTemplete/DataFlow/M02_DF_Api";
|
||||
|
||||
const MACHINE = /^\d{4}-\d{4}$/;
|
||||
let names: Record<string, string> = {};
|
||||
|
||||
/** 고르기 글 — 기계 원문번호는 이름 · 규격을 앞에 */
|
||||
export const optionText = (x: string, known: Record<string, string> = names): string =>
|
||||
known[x] ? `${known[x]} (${x})` : x;
|
||||
|
||||
/** 목록이 모두 원문번호(빈 칸 빼고)면 이름을 받아 `redraw`(글만 다시) — 수식 탭 옵션 · 단가산출 상세 옵션 */
|
||||
export function nameMachines(list: string[], redraw: () => void): void {
|
||||
const codes = list.filter(Boolean);
|
||||
if (codes.length && codes.every((x) => MACHINE.test(x)) && codes.some((x) => !(x in names)))
|
||||
void cfMachineNames().then((got) => {
|
||||
names = got;
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOptions(
|
||||
doc: FormulaDoc | null,
|
||||
@@ -22,9 +41,10 @@ export function buildOptions(
|
||||
const now = k in given ? given[k] : v.연결?.값;
|
||||
if (kind === "선택") {
|
||||
const list = (v.연결?.목록 ?? []).map(String);
|
||||
const items = () => list.map((x) => ({ value: x, text: optionText(x) }));
|
||||
const f = createSelectField({
|
||||
label,
|
||||
options: list.map((x) => ({ value: x, text: x })),
|
||||
options: items(),
|
||||
value: String(now ?? ""),
|
||||
compact: true,
|
||||
size: 28,
|
||||
@@ -32,6 +52,7 @@ export function buildOptions(
|
||||
onChange: (x) => set(k, v, x),
|
||||
});
|
||||
f.root.dataset.option = k;
|
||||
nameMachines(list, () => f.setOptions(items(), f.select.value));
|
||||
out.push(f.root);
|
||||
} else {
|
||||
const f = createInputField({
|
||||
|
||||
@@ -162,14 +162,55 @@
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 공식 묶음 = 폭만큼 여러 열(요구 692-3 · 자동 채움) · 머리 · 식 글 줄 · 고치는 줄 = 열 합침 · 긴 이름 = 줄임표 */
|
||||
.lstk-line.k-공식 {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
align-items: center;
|
||||
column-gap: var(--spacing-16);
|
||||
position: relative;
|
||||
padding-inline-start: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-line.k-공식 > .lstk-fhead,
|
||||
.lstk-line.k-공식 > .lstk-frow:is(.is-open, :has(> .lstk-expr)) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.lstk-line.k-공식 > .lstk-frow:not(.is-open, :has(> .lstk-expr)) {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.lstk-line.k-공식 > .lstk-frow:not(.is-open) > .lstk-f-m {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.lstk-frow > .lstk-sym {
|
||||
flex: none;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstk-frow > .lstk-f-m,
|
||||
.lstk-frow > .lstk-unit,
|
||||
.lstk-rtools {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lstk-rtools {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.lstk-frow > .lstk-tag {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstk-line > .lstk-x,
|
||||
.lstk-fhead > .lstk-x {
|
||||
margin-inline-start: auto;
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface EditHooks {
|
||||
pair: (loc: string) => void;
|
||||
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
|
||||
take?: StackTake;
|
||||
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
|
||||
before?: ReadonlyMap<string, unknown>;
|
||||
}
|
||||
|
||||
const field = (
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
DocError,
|
||||
rename,
|
||||
splitId,
|
||||
textOf,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
|
||||
import {
|
||||
dropSyms,
|
||||
freshId,
|
||||
@@ -44,6 +46,8 @@ import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pi
|
||||
import type { EditHooks } from "./line_stack_edit";
|
||||
import { openTablePick } from "./line_stack_pick";
|
||||
import { lt } from "./line_stack_text";
|
||||
import { whyText } from "./line_stack_why";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
type Block = Extract<StackLine, { 꼴: "공식" }>;
|
||||
@@ -56,6 +60,10 @@ const LINK_TAG: Record<string, string> = {
|
||||
입력: "입력",
|
||||
};
|
||||
|
||||
/** 출처 꼬리표 — 긴 글은 칸 안에서 줄임표 · 마우스 글에 전체(요구 692-3) */
|
||||
const clipTag = (text: string): HTMLElement =>
|
||||
el("span", { className: "lstk-tag", text, attrs: { title: text } });
|
||||
|
||||
const input = (
|
||||
value: string,
|
||||
placeholder: string,
|
||||
@@ -107,7 +115,7 @@ export function buildBlock(
|
||||
const tplName = (k: string): string =>
|
||||
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
|
||||
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
|
||||
const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id));
|
||||
const tableOnly = line.기호.every((id) => !!vars.get(id)?.연결?.표 && !lines.has(id)); // 직접 친 표 값도
|
||||
const label = !line.틀
|
||||
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
|
||||
: changed
|
||||
@@ -148,6 +156,7 @@ export function buildBlock(
|
||||
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3)
|
||||
onClick: () =>
|
||||
void h.table(String(link.표 ?? "")).then((t) => {
|
||||
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
|
||||
@@ -161,7 +170,7 @@ export function buildBlock(
|
||||
}),
|
||||
});
|
||||
b.dataset.tablePick = id;
|
||||
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
|
||||
b.title = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`;
|
||||
return b;
|
||||
};
|
||||
|
||||
@@ -216,17 +225,28 @@ export function buildBlock(
|
||||
.map((id) => {
|
||||
const v = vars.get(id);
|
||||
const link = v?.연결;
|
||||
const expr = lines.get(id);
|
||||
const raw = lines.get(id);
|
||||
// 만약 식 = 결정된 식(지금 옵션 · 중간값으로 탄 갈래만 · 요구 695) — 고치면 그 글로 굳음(만약 사라짐 · 696)
|
||||
const tree = raw !== undefined ? tryParse(raw) : null;
|
||||
const iffy = !!tree && hasIf(tree);
|
||||
const expr = iffy ? textOf(decide(tree!, vals)) : raw;
|
||||
const was = h.before?.get(id);
|
||||
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
|
||||
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
|
||||
const tag =
|
||||
link?.갈래 === "표" && !expr
|
||||
? tableBtn(id, link)
|
||||
: link && LINK_TAG[link.갈래 ?? ""] && !expr
|
||||
: link?.갈래 === "숫자" && link.표 && !expr
|
||||
? el("span", {
|
||||
className: "lstk-tag",
|
||||
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
|
||||
text: lt("Direct"),
|
||||
attrs: { title: lt("Direct_Hint") },
|
||||
})
|
||||
: null;
|
||||
: link && LINK_TAG[link.갈래 ?? ""] && !expr
|
||||
? clipTag(
|
||||
`${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
|
||||
)
|
||||
: null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (sub?.결과 && link?.키)
|
||||
tools.push(
|
||||
@@ -259,6 +279,7 @@ export function buildBlock(
|
||||
}),
|
||||
);
|
||||
const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id);
|
||||
name.title = `${show(id)} — ${name.title}`; // 줄임표로 잘려도 전체 이름
|
||||
const valueOf =
|
||||
expr ??
|
||||
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
|
||||
@@ -300,10 +321,11 @@ export function buildBlock(
|
||||
body = [
|
||||
name,
|
||||
"=",
|
||||
...(expr
|
||||
? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id), "="]
|
||||
: []),
|
||||
el("span", { className: "lstk-val", text: valText(id) }),
|
||||
...(expr ? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id)] : []),
|
||||
// 결정된 식이 수 하나면(만약이 수를 탄 줄) 값을 또 적지 않음
|
||||
...(expr && iffy && tryParse(expr)?.[0] === "num"
|
||||
? []
|
||||
: [...(expr ? ["="] : []), el("span", { className: "lstk-val", text: valText(id) })]),
|
||||
];
|
||||
else
|
||||
body = [
|
||||
@@ -316,9 +338,26 @@ export function buildBlock(
|
||||
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
|
||||
: []),
|
||||
];
|
||||
const why = iffy
|
||||
? whyText(tree!, vals, {
|
||||
label: (x) => vars.get(x)?.이름 || splitId(x)[1] || x,
|
||||
shown: shownText,
|
||||
fmt: (x) => eqText(x),
|
||||
})
|
||||
: "";
|
||||
const notes = [
|
||||
...(why ? [createText(why)] : []),
|
||||
...(freed ? [createText(lt("If_Freed"), "warning")] : []),
|
||||
];
|
||||
notes.forEach((n) => n.classList.add("lstk-wide", "lstk-why"));
|
||||
const row = el("div", {
|
||||
className: `lstk-frow ${open ? "is-open" : ""}`,
|
||||
children: [...body, ...(tag ? [tag] : []), ...tools],
|
||||
children: [
|
||||
...body,
|
||||
...(tag ? [tag] : []),
|
||||
...(tools.length ? [el("span", { className: "lstk-rtools", children: tools })] : []),
|
||||
...notes,
|
||||
],
|
||||
});
|
||||
row.dataset.sym = id;
|
||||
return row;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_do
|
||||
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import { lineMap } from "../common_util/common_util_formula_doc_view";
|
||||
import {
|
||||
docOf,
|
||||
stackOf,
|
||||
@@ -326,6 +327,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
open: blocks,
|
||||
pair: (loc) => pair(loc, "left"),
|
||||
take: takeCtx(),
|
||||
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -11,7 +11,7 @@ import "../config/node_modules/katex/dist/katex.min.css";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { splitId, type Node } from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
import { decide, hasIf, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
|
||||
export interface TexCtx {
|
||||
side: "sym" | "val";
|
||||
@@ -23,6 +23,8 @@ export interface TexCtx {
|
||||
place?: Record<string, string>;
|
||||
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
|
||||
alias?: Record<string, string>;
|
||||
/** 이미 결정된 나무(다시 안 접음) */
|
||||
decided?: boolean;
|
||||
}
|
||||
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
@@ -85,6 +87,8 @@ const paren = (t: string): string => `\\left(${t}\\right)`;
|
||||
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
|
||||
|
||||
export function tex(n: Node, c: TexCtx): string {
|
||||
// 만약 = 탄 갈래만 · 만약이 낳은 0 · 1 접음 — 왼쪽 결정된 식과 같은 `decide` 한 벌(요구 695)
|
||||
if (!c.decided && hasIf(n)) return tex(decide(n, c.vals), { ...c, decided: true });
|
||||
const go = (x: Node): string => tex(x, c);
|
||||
switch (n[0]) {
|
||||
case "num":
|
||||
@@ -114,21 +118,12 @@ export function tex(n: Node, c: TexCtx): string {
|
||||
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
|
||||
case "call": {
|
||||
const [, name, items] = n;
|
||||
if (name === "만약") {
|
||||
const pick = takenOf(n, c.vals);
|
||||
if (pick) return go(items[pick]);
|
||||
}
|
||||
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
|
||||
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
|
||||
return `${fn}${paren(items.map(go).join(",\\,"))}`;
|
||||
}
|
||||
case "bin": {
|
||||
const [, o, a, b] = n;
|
||||
const na = leafNum(a, c.vals);
|
||||
const nb = leafNum(b, c.vals);
|
||||
if ((o === "+" || o === "-") && nb === 0) return go(a);
|
||||
if ((o === "*" || o === "/") && nb === 1) return go(a);
|
||||
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
|
||||
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
|
||||
const mine = PREC[o];
|
||||
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
|
||||
|
||||
@@ -65,6 +65,13 @@ const TEXT = {
|
||||
Repick: ["찾아 바꾸기", "Find and change"],
|
||||
Close: ["닫기", "Close"],
|
||||
Tbl: ["표", "Table"],
|
||||
Direct: ["직접 입력", "Typed"],
|
||||
If_Not: [" 아님", " not"],
|
||||
If_Freed: [
|
||||
"옵션 규칙이 풀렸음 · 되돌리려면 [되돌림]",
|
||||
"Option rule released — use [Undo] to restore",
|
||||
],
|
||||
Direct_Hint: ["표 값을 손으로 고침 — 표 연결 끊김", "Table value typed by hand — table link off"],
|
||||
Tbl_Missing: ["표를 못 읽음", "Table not found"],
|
||||
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
|
||||
Tbl_Bound: [
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* =============================================================================
|
||||
* line_stack_why.ts
|
||||
* 결정된 식 근거 한 줄(요구 695 · 696) — 탄 길 위 만약마다 「조건 → 탄 갈래」
|
||||
* 옵션 비교 = 「지붕슬래브 「아님」 아님 → 17」 · 수치 비교 = 「H = 9 > 7 → …」 · 그 밖 = 조건 글 + 아님
|
||||
* 판정 · 결정은 `common_util_formula_doc_view` (decide · whyOf) — 여기는 글만
|
||||
* ========================================================================== */
|
||||
|
||||
import { textOf, type Node } from "../common_util/common_util_formula_doc";
|
||||
import { whyOf, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
export interface WhyCtx {
|
||||
/** 옵션 기호 → 화면 이름(변수 이름) */
|
||||
label: (id: string) => string;
|
||||
/** 식 글 → 화면 글(표 값 별칭) */
|
||||
shown: (text: string) => string;
|
||||
/** 값 → 글 */
|
||||
fmt: (v: unknown) => string;
|
||||
}
|
||||
|
||||
const OP: Record<string, string> = {
|
||||
"<": "<",
|
||||
"<=": "≤",
|
||||
">": ">",
|
||||
">=": "≥",
|
||||
"==": "=",
|
||||
"!=": "≠",
|
||||
};
|
||||
|
||||
function condText(c: Node, yes: boolean, vals: Values, x: WhyCtx): string {
|
||||
const no = lt("If_Not");
|
||||
if (c[0] === "bin" && (c[1] === "==" || c[1] === "!=")) {
|
||||
const [v, s] =
|
||||
c[2][0] === "var" && c[3][0] === "str"
|
||||
? [c[2], c[3]]
|
||||
: c[3][0] === "var" && c[2][0] === "str"
|
||||
? [c[3], c[2]]
|
||||
: [null, null];
|
||||
if (v && s && typeof vals[v[1]] === "string")
|
||||
return `${x.label(v[1])} 「${s[1]}」${(c[1] === "==" ? !yes : yes) ? no : ""}`;
|
||||
}
|
||||
if (c[0] === "bin" && OP[c[1]]) {
|
||||
const side = (n: Node): string =>
|
||||
n[0] === "var" ? `${x.shown(n[1])} = ${x.fmt(vals[n[1]])}` : x.shown(textOf(n));
|
||||
return `${side(c[2])} ${OP[c[1]]} ${side(c[3])}${yes ? "" : no}`;
|
||||
}
|
||||
return `${x.shown(textOf(c))}${yes ? "" : no}`;
|
||||
}
|
||||
|
||||
/** 근거 한 줄 — 만약 없으면 빈 글 */
|
||||
export const whyText = (n: Node, vals: Values, x: WhyCtx): string =>
|
||||
whyOf(n, vals)
|
||||
.map((w) => `${condText(w.cond, w.yes, vals, x)} → ${x.shown(textOf(w.to))}`)
|
||||
.join(" · ");
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
||||
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
||||
* 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장]
|
||||
* 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · 목록에선 [신규 로직]만(요구 693)
|
||||
* [저장] = 기본정보 · 별칭(로직 줄 `/save`) + 수식(줄 쌓기 `/logic/doc/save`) 한 번에 · [되돌림] = 둘 다 저장 뒤 상태로
|
||||
* [로직 삭제] = 확인 → 서버 정본에서 바로(`/logic/remove` — 로직 줄 + 수식 문서 · 부르는 곳 있으면 막고 까닭) → 로직 목록
|
||||
* ========================================================================== */
|
||||
@@ -234,6 +234,8 @@ export async function mountM01LogicLab(
|
||||
waiting.hidden = loaded;
|
||||
panes.root.hidden = !opened;
|
||||
back.hidden = views.root.hidden = listing;
|
||||
// 로직을 골라야 뜻 있는 단추 = 목록에선 숨김(요구 693 · [신규 로직]만 보임)
|
||||
copyButton.hidden = deleteButton.hidden = discardButton.hidden = saveButton.hidden = listing;
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true }));
|
||||
@@ -528,6 +530,12 @@ export async function mountM01LogicLab(
|
||||
})
|
||||
: showToast(tl("Copy_NeedOpen"), "info"),
|
||||
});
|
||||
const deleteButton = createButton({
|
||||
label: tx("Bar_Delete"),
|
||||
variant: "danger",
|
||||
iconStart: "trash",
|
||||
onClick: () => void onDelete(),
|
||||
});
|
||||
// 제목 줄 = 요소 화면(인력 · 재료 …)과 같은 공용 조각
|
||||
const bar = buildHeadBar({
|
||||
lead: [back],
|
||||
@@ -542,12 +550,7 @@ export async function mountM01LogicLab(
|
||||
onClick: () => void onNew(),
|
||||
}),
|
||||
copyButton,
|
||||
createButton({
|
||||
label: tx("Bar_Delete"),
|
||||
variant: "danger",
|
||||
iconStart: "trash",
|
||||
onClick: () => void onDelete(),
|
||||
}),
|
||||
deleteButton,
|
||||
discardButton,
|
||||
saveButton,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Api.ts
|
||||
* 단위 · 금액 표준 표 읽기 — 서버 `GET /api/m02/df/standards`(`M02_DF_Router.py`)
|
||||
* 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`)
|
||||
* 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`) · 옵션 기계 이름(`formula_doc_options`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -18,3 +18,13 @@ export async function cfStandards(): Promise<CfStandards> {
|
||||
if (!response.ok) throw new Error(`표준 표 읽기 실패 (${response.status})`);
|
||||
return (await response.json()) as CfStandards;
|
||||
}
|
||||
|
||||
let machineNames: Promise<Record<string, string>> | null = null;
|
||||
|
||||
/** 기계 원문번호 → 「이름 규격」 — 한 번 받아 둠(못 받으면 빈 표 · 다음에 다시) */
|
||||
export function cfMachineNames(): Promise<Record<string, string>> {
|
||||
machineNames ??= fetch(`${API_BASE_URL}/m02/df/machine-names`, { credentials: "include" })
|
||||
.then((r) => (r.ok ? (r.json() as Promise<Record<string, string>>) : Promise.reject()))
|
||||
.catch(() => ((machineNames = null), {}));
|
||||
return machineNames;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved}.
|
||||
"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved} · 기계 이름 `GET /machine-names`.
|
||||
|
||||
부르는 곳 = 단위 고르기(`M02_DF_UnitPick.ts`) · 끝수 표준(`M02_DF_Round.ts`).
|
||||
표 = `_standard/` 파일(git 이 따라감) · 권한 = 로그인만(읽기만 · `main.py` 한 줄 · M02 줄 쌓기 단위 고르기).
|
||||
@@ -19,6 +19,20 @@ router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow"])
|
||||
STANDARD = Path(__file__).resolve().parent / "_standard"
|
||||
|
||||
|
||||
@router.get("/machine-names")
|
||||
def get_machine_names() -> dict:
|
||||
"""기계 원문번호 → 「이름 규격」(요구 692-2) — 옵션 드롭다운 라벨만 · 값은 원문번호 그대로 · 겹친 번호는 첫 줄."""
|
||||
out: dict[str, str] = {}
|
||||
for data in store.loaded()[0].values():
|
||||
if data.get("그룹") != "기계":
|
||||
continue
|
||||
for row in data.get(store.items_key(data)) or []:
|
||||
code = str(row.get("원문번호") or "")
|
||||
if code and code not in out:
|
||||
out[code] = f"{row.get('이름') or ''} {row.get('규격') or ''}".strip()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/standards")
|
||||
def get_standards() -> dict:
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_UnitPick.ts
|
||||
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음 · `free` = 목록에 없는 친 글도 씀) · 「-」 = 없음
|
||||
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
|
||||
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 표의 「더한 단위」(수식 문서 줄 단위) + 부른 쪽이 주는 마스터 결과단위 등 + 원 · 「같은 단위」(kg → ㎏) 는 하나만
|
||||
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 서버 /standards) — 다른 판도 이것을 씀
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
@@ -43,13 +43,22 @@ interface StandardRow {
|
||||
|
||||
/** 서버 표준 표(/standards) — 고친 표가 있으면 그것, 없으면 처음 값 */
|
||||
const fromServer: UnitSource = async () => {
|
||||
const table = (await cfStandards()).unit as { 줄: StandardRow[] };
|
||||
const units = table.줄
|
||||
.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]])
|
||||
const table = (await cfStandards()).unit as {
|
||||
줄: StandardRow[];
|
||||
"더한 단위"?: string[];
|
||||
"같은 단위"?: Record<string, string>;
|
||||
};
|
||||
const same = (u: string | null | undefined): string | null | undefined =>
|
||||
(u && table["같은 단위"]?.[u]) || u; // kg → ㎏ 등 원문 꼴 하나만(요구 692)
|
||||
const units = [
|
||||
...table.줄.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]]),
|
||||
...(table["더한 단위"] ?? []),
|
||||
]
|
||||
.map(same)
|
||||
.filter((u): u is string => !!u);
|
||||
const count: Record<string, Record<number, number>> = {};
|
||||
for (const r of table.줄) {
|
||||
const u = r["단위수량 단위"];
|
||||
const u = same(r["단위수량 단위"]);
|
||||
const p = r["단위수량 소수자리"];
|
||||
if (!u || u === "없음" || typeof p !== "number") continue;
|
||||
count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 };
|
||||
|
||||
@@ -676,5 +676,33 @@
|
||||
"① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용한다.",
|
||||
"② 품셈 각 항목에서 제시한 소숫자리가 본 표의 내용과 상이할 경우 항목에서 제시하는 소숫자리를 우선하여 적용한다.",
|
||||
"③ 본 표에 제시하지 않은 품의 경우 유사품의 규격과 단위수량을 참고하여 적용하며, C.G.S 단위로 하는 것을 원칙으로 한다."
|
||||
]
|
||||
],
|
||||
"더한 단위": [
|
||||
"시간",
|
||||
"식",
|
||||
"㎏",
|
||||
"L",
|
||||
"본",
|
||||
"㎾h",
|
||||
"조",
|
||||
"EA",
|
||||
"대",
|
||||
"Sh",
|
||||
"g",
|
||||
"동",
|
||||
"통",
|
||||
"병",
|
||||
"㎝",
|
||||
"D/M",
|
||||
"연",
|
||||
"SET",
|
||||
"회",
|
||||
"포",
|
||||
"족"
|
||||
],
|
||||
"같은 단위": {
|
||||
"kg": "㎏",
|
||||
"cm": "㎝"
|
||||
},
|
||||
"더함": "「더한 단위」 = 표에 없는 수식 문서 항목 줄 단위(요구 692) · 「같은 단위」 = 원문 꼴(건설품셈 1-2-2 · ㎏ · ㎝)로 하나만 보임"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,11 @@ html.is-bm-route .m02-main__head {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
/* 원문 손잡이 = 브라우저 오른쪽 끝(요구 694 · M01 687-17 과 같은 규칙) — 메인 칸 오른쪽 여백(24)만큼 밀어 붙임 · 본문이 그 자리까지 */
|
||||
html:not([data-size="compact"], [data-size="medium"]) .m02-main__host > .ui-side-panels-row {
|
||||
margin-inline-end: calc(-1 * var(--spacing-24));
|
||||
}
|
||||
|
||||
/* 면적산출 · 재료산출 전체 편집 — `.ss` 는 height:100% 로 부모 높이를 받음 · 도면 편집과 같이 메인 칸을 화면 높이로 채움 */
|
||||
.m02-main:has(> .m02-main__host > .ss) {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* common_util_formula_doc_view.ts
|
||||
* 수식 문서 그림용 값(PLAN 802-3 · 802-4b-2 · 요구 663-1) — 화면 · DOM · fetch 없음(거울 시험이 Node 로 돌림)
|
||||
* tryParse · evalNode(탄 갈래 값) · takenOf · leafNum · offZero / offLine(선택으로 꺼진 줄)
|
||||
* decide · whyOf — 만약 = 탄 갈래만 · 만약이 낳은 0 더하기 · 1 곱하기 없앰(왼쪽 식 글 · 오른쪽 산출근거 한 벌 · 요구 695)
|
||||
* 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) — 여기는 그림 · 숨김 판정만
|
||||
* ========================================================================== */
|
||||
|
||||
import { DocError, parse, type Node } from "./common_util_formula_doc";
|
||||
import { DocError, parse, walk, type Node } from "./common_util_formula_doc";
|
||||
import { binary, cut, isNum } from "./common_util_formula_doc_calc";
|
||||
|
||||
export type Values = Record<string, unknown>;
|
||||
@@ -150,3 +151,97 @@ export function moneyScope(rows: unknown, i: number): Values {
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 결정된 식 — 지금 값으로 탄 갈래만 남긴 나무(모르는 조건의 만약은 그대로) · 셈 값은 원 식과 같음
|
||||
* 아래에서 위로: 만약이 낳은 수(made)는 접음(-0 · 0 ÷ 100 · 1 - 0 → 1) · 0 더하기 · 빼기 · 1 곱하기 · 나누기 · 0 곱하기 없앰 ·
|
||||
* 만약이 낳은 음수 부호는 위로 올려 「a - -x」 → 「a + x」 · 만약 없는 식 조각은 손대지 않음 */
|
||||
export function decide(n: Node, vals: Values): Node {
|
||||
const made = new WeakSet<Node>(); // 만약이 낳은 수
|
||||
const sign = new WeakSet<Node>(); // 만약에서 온 「-x」
|
||||
const num = (v: number): Node => {
|
||||
const x: Node = ["num", v, String(Number(v.toPrecision(12)))];
|
||||
made.add(x);
|
||||
return x;
|
||||
};
|
||||
const isMade = (x: Node): x is ["num", number, string] => x[0] === "num" && made.has(x);
|
||||
const neg = (x: Node): Node => {
|
||||
if (x[0] === "num") return num(-x[1]);
|
||||
if (x[0] === "neg") return x[1];
|
||||
const y: Node = ["neg", x];
|
||||
sign.add(y);
|
||||
return y;
|
||||
};
|
||||
const go = (x: Node): Node => {
|
||||
if (!hasIf(x)) return x;
|
||||
switch (x[0]) {
|
||||
case "call": {
|
||||
const pick = takenOf(x, vals);
|
||||
if (x[1] === "만약" && pick) {
|
||||
const r = go(x[2][pick]);
|
||||
return r[0] === "num" ? num(r[1]) : r[0] === "neg" ? neg(r[1]) : r;
|
||||
}
|
||||
return ["call", x[1], x[2].map(go)];
|
||||
}
|
||||
case "neg": {
|
||||
const c = go(x[1]);
|
||||
const fromIf = x[1][0] === "call" && x[1][1] === "만약"; // 「-만약(…)」 = 만약에서 온 부호
|
||||
return fromIf || isMade(c) || sign.has(c) || c[0] === "neg" ? neg(c) : ["neg", c];
|
||||
}
|
||||
case "pct":
|
||||
return ["pct", go(x[1])];
|
||||
case "field":
|
||||
return ["field", go(x[1]), x[2]];
|
||||
case "bin": {
|
||||
const o = x[1];
|
||||
const a = go(x[2]);
|
||||
const b = go(x[3]);
|
||||
if (!"+-*/".includes(o) || o.length !== 1) return ["bin", o, a, b];
|
||||
const ma = isMade(a);
|
||||
const mb = isMade(b);
|
||||
if (a[0] === "num" && b[0] === "num" && (ma || mb)) {
|
||||
const v = binary(o, a[1], b[1]);
|
||||
if (typeof v === "number" && Number.isFinite(v)) return num(v);
|
||||
}
|
||||
if ((o === "+" || o === "-") && mb && b[1] === 0) return a;
|
||||
if (o === "+" && ma && a[1] === 0) return b;
|
||||
if (o === "-" && ma && a[1] === 0) return neg(b);
|
||||
if ((o === "*" || o === "/") && mb && b[1] === 1) return a;
|
||||
if (o === "*" && ma && a[1] === 1) return b;
|
||||
if (
|
||||
(o === "*" && ((ma && a[1] === 0) || (mb && b[1] === 0))) ||
|
||||
(o === "/" && ma && a[1] === 0)
|
||||
)
|
||||
return num(0);
|
||||
if ((o === "*" || o === "/") && sign.has(a))
|
||||
return neg(["bin", o, (a as ["neg", Node])[1], b]);
|
||||
if (o === "-" && sign.has(b)) return ["bin", "+", a, (b as ["neg", Node])[1]];
|
||||
if (o === "+" && sign.has(b)) return ["bin", "-", a, (b as ["neg", Node])[1]];
|
||||
return ["bin", o, a, b];
|
||||
}
|
||||
default:
|
||||
return x;
|
||||
}
|
||||
};
|
||||
return go(n);
|
||||
}
|
||||
|
||||
export const hasIf = (n: Node): boolean =>
|
||||
[...walk(n)].some((x) => x[0] === "call" && x[1] === "만약");
|
||||
|
||||
/** 결정 근거 — 탄 길 위 만약마다 {조건, 참?, 탄 갈래(결정된 꼴)} · 안 탄 갈래 속 만약은 안 셈 */
|
||||
export function whyOf(n: Node, vals: Values): { cond: Node; yes: boolean; to: Node }[] {
|
||||
const out: { cond: Node; yes: boolean; to: Node }[] = [];
|
||||
const visit = (x: Node): void => {
|
||||
const pick = takenOf(x, vals);
|
||||
if (pick) {
|
||||
const items = (x as ["call", string, Node[]])[2];
|
||||
out.push({ cond: items[0], yes: pick === 1, to: decide(items[pick], vals) });
|
||||
return visit(items[pick]);
|
||||
}
|
||||
if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") visit(x[1]);
|
||||
else if (x[0] === "call") x[2].forEach(visit);
|
||||
else if (x[0] === "bin") (visit(x[2]), visit(x[3]));
|
||||
};
|
||||
visit(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -337,14 +337,15 @@ export function setRow(doc: FormulaDoc, id: string, text: string): void {
|
||||
const n = nodeOf(t);
|
||||
const plain = n && (n[0] === "num" || (n[0] === "neg" && n[1][0] === "num"));
|
||||
let v = (doc.변수 ?? []).find((x) => varId(x) === id);
|
||||
const from = v?.연결?.표 ? { 표: v.연결.표, 칸: v.연결.칸, 조건: v.연결.조건 } : {}; // 표에서 온 기호 = 별칭 · 꼬리표 남김(요구 692)
|
||||
if (plain || !t) {
|
||||
doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id);
|
||||
if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "숫자" })));
|
||||
const value = t ? Number(t) : null;
|
||||
v.연결 = { 갈래: "숫자", 값: Number.isFinite(value) ? value : t };
|
||||
v.연결 = { ...from, 갈래: "숫자", 값: Number.isFinite(value) ? value : t };
|
||||
return;
|
||||
}
|
||||
if (v && v.연결?.갈래 !== "식") v.연결 = { 갈래: "식" };
|
||||
if (v && v.연결?.갈래 !== "식") v.연결 = { ...from, 갈래: "식" };
|
||||
const line = (doc.식 ?? []).find((l) => l.기호 === id);
|
||||
if (line) line.식 = text.trim();
|
||||
else (doc.식 ??= []).push({ 기호: id, 식: text.trim() });
|
||||
|
||||
@@ -155,7 +155,8 @@ export function stackLevels(items: StackItem[]): { item: number; line: number }[
|
||||
}
|
||||
|
||||
/** 표 값 기호 화면 이름(804-5 · 요구 683) — 「Q_수량2^표」 → 「Q_작업반장」(고른 줄 글 조건 첫 낱말) · 글 조건 없으면 「^표」만 뗌
|
||||
* · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) */
|
||||
* · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감)
|
||||
* · 직접 친 값(숫자 · 식)도 표 · 칸 · 조건을 남기므로 같은 이름(요구 692) */
|
||||
export function tableAlias(doc: FormulaDoc): Record<string, string> {
|
||||
const taken = new Set([
|
||||
...symbols(doc),
|
||||
@@ -167,7 +168,7 @@ export function tableAlias(doc: FormulaDoc): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const v of doc.변수 ?? []) {
|
||||
const link = v.연결;
|
||||
if (link?.갈래 !== "표") continue;
|
||||
if (!link?.표) continue;
|
||||
const id = varId(v);
|
||||
const [s, lo, up] = splitId(id);
|
||||
const said = Object.values(link.조건 ?? {})
|
||||
|
||||
@@ -239,6 +239,45 @@ function aliasOf({ 문서 }) {
|
||||
return { 별칭: alias, 틀림: bad };
|
||||
}
|
||||
|
||||
/* 692-1 표 값 직접 침 — 숫자 두 번 · 식 한 번 쳐도 별칭 같음 · 고친 문서(서버 셈 대조) */
|
||||
function directOf({ 문서 }) {
|
||||
const before = pk2.tableAlias(문서);
|
||||
const doc = JSON.parse(JSON.stringify(문서));
|
||||
const ids = Object.keys(before);
|
||||
for (const id of ids) ls.setRow(doc, id, "7");
|
||||
for (const id of ids) ls.setRow(doc, id, "8");
|
||||
const typed = pk2.tableAlias(doc);
|
||||
const expr = JSON.parse(JSON.stringify(doc));
|
||||
for (const id of ids) ls.setRow(expr, id, "8 + 0");
|
||||
const kinds = ids.map((id) => varOf(doc, id).연결.갈래);
|
||||
return { 전: before, 숫자: typed, 식: pk2.tableAlias(expr), 갈래: kinds, 문서: doc };
|
||||
}
|
||||
|
||||
/* 695 결정된 식 — 만약 든 식 줄마다 결정된 식 값 = 원 식 값 = 셈 답 · 남은 만약 · 근거 수 */
|
||||
const vw = util("common_util_formula_doc_view");
|
||||
function decideOf({ 문서, 연결 }) {
|
||||
const c = dc.createDocCache();
|
||||
c.merge(연결);
|
||||
const vals = calc.calcDoc(문서, c.links, {}).값 || {};
|
||||
const out = [];
|
||||
for (const l of 문서.식 || []) {
|
||||
const t = vw.tryParse(l.식);
|
||||
if (!t || !vw.hasIf(t)) continue;
|
||||
const d = vw.decide(t, vals);
|
||||
const a = vw.evalNode(t, vals);
|
||||
const b = vw.evalNode(d, vals);
|
||||
out.push({
|
||||
기호: l.기호,
|
||||
같음: a === undefined ? b === undefined : calc.same(a, b),
|
||||
답: a === undefined || calc.same(a, vals[l.기호]),
|
||||
남은만약: vw.hasIf(d),
|
||||
근거: vw.whyOf(t, vals).length,
|
||||
글: fd.textOf(d),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const job = {
|
||||
왕복: roundTrip,
|
||||
@@ -251,5 +290,7 @@ const job = {
|
||||
옮김: moveAll,
|
||||
번지: placeOf,
|
||||
별칭: aliasOf,
|
||||
직접: directOf,
|
||||
결정: decideOf,
|
||||
}[input.할일];
|
||||
process.stdout.write(JSON.stringify(input.문서들.map(job)));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""804-10(요구 692-2) — 옵션 드롭다운 기계 · 집게 등 원문번호 목록 = 「이름 규격 (원문번호)」 글 · 값은 그대로.
|
||||
|
||||
① 서버 `GET /api/m02/df/machine-names` = 기계 원문번호 → 「이름 규격」(로그인만)
|
||||
② 수식 문서 선택 목록 중 모두 원문번호 꼴인 목록 — 번호마다 이름이 있음(1,518 전수)
|
||||
③ 화면 부품 `formula_doc_options.ts` — 글만 바꿈(value = 원문번호) · 이름은 한 번 받아 둠
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Router as df
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CODE = re.compile(r"^\d{4}-\d{4}$")
|
||||
|
||||
|
||||
def test_기계_이름_표_문서_목록_전수() -> None:
|
||||
names = df.get_machine_names()
|
||||
assert names["0101-0007"] == "불도저(무한궤도) 7ton"
|
||||
lists: list[list[str]] = []
|
||||
for f in (ROOT / "resources/master_data/수식문서").glob("*.json"):
|
||||
for doc in json.loads(f.read_text(encoding="utf-8")).get("줄") or []:
|
||||
for v in doc.get("변수") or []:
|
||||
link = v.get("연결") or {}
|
||||
got = [str(x) for x in link.get("목록") or []]
|
||||
if link.get("갈래") == "선택" and got and all(CODE.match(x) for x in got):
|
||||
lists.append(got)
|
||||
assert len(lists) > 200
|
||||
missing = {x for got in lists for x in got if not names.get(x)}
|
||||
assert not missing, sorted(missing)[:10]
|
||||
|
||||
|
||||
def test_옵션_부품_글만_바꿈() -> None:
|
||||
src = (ROOT / "A00_Common/formula_doc_options.ts").read_text(encoding="utf-8")
|
||||
assert "known[x] ? `${known[x]} (${x})` : x" in src
|
||||
assert "list.map((x) => ({ value: x, text: optionText(x) }))" in src
|
||||
assert "nameMachines(list, () => f.setOptions(items(), f.select.value));" in src
|
||||
api = (ROOT / "M02_MasterTemplete/DataFlow/M02_DF_Api.ts").read_text(encoding="utf-8")
|
||||
assert "machineNames ??= fetch(" in api # 한 번만 받음
|
||||
main = (ROOT / "main.py").read_text(encoding="utf-8")
|
||||
at = main.index("M02_MasterTemplete.DataFlow.M02_DF_Router")
|
||||
assert "dependencies=protected," in main[at : at + 120]
|
||||
|
||||
|
||||
def test_상세_탭_옵션_같은_도우미() -> None:
|
||||
sheet = (ROOT / "A00_Common/calc_sheet.ts").read_text(encoding="utf-8")
|
||||
assert "nameMachines(list, () => f.setOptions(items(), f.select.value));" in sheet
|
||||
assert "text: optionText(o)" in sheet
|
||||
@@ -0,0 +1,38 @@
|
||||
"""804-11(요구 693 · 694) — M01 목록 화면 머리 단추 · M02 원문 손잡이 오른쪽 끝.
|
||||
|
||||
① M01 단가산출 기초 목록 = [신규 로직]만 · 로직을 열면 [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] 나타남(숨김 · 흐림 아님)
|
||||
요소 묶음(인력 · 재료 · 유가 · 기계 · 소요량 · 계수 · 환율 · 요율) · 구조물 옵션 정의 · 별칭 장부 = 항목 고름에 매인 머리 단추 없음(그대로)
|
||||
② M02 원문 손잡이 = M01(804-7 · 687-17)과 같은 규칙 — 메인 칸 오른쪽 여백만큼 밀어 붙임(단가산출 · 일위대가 등 `m02-main__host` 바로 아래 칸)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_M01_목록_머리_단추_숨김() -> None:
|
||||
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
|
||||
line = "copyButton.hidden = deleteButton.hidden = discardButton.hidden = saveButton.hidden = listing;"
|
||||
assert line in lab
|
||||
at = lab.index("const drawEditor = (): void => {")
|
||||
assert at < lab.index(line) < lab.index("if (!opened) return;", at) # 목록 갈래 return 앞
|
||||
assert (
|
||||
" copyButton,\n deleteButton,\n discardButton,\n saveButton,"
|
||||
in lab.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def test_M02_원문_손잡이_오른쪽_끝() -> None:
|
||||
css = _src("M02_MasterTemplete/M02_MasterTemplete_UI_Style.css")
|
||||
m02 = ".m02-main__host > .ui-side-panels-row {\n margin-inline-end: calc(-1 * var(--spacing-24));"
|
||||
assert m02 in css.replace("\r\n", "\n")
|
||||
assert "padding: var(--spacing-16) var(--spacing-24);" in css # 민 만큼 = 메인 칸 오른쪽 여백
|
||||
m01 = _src("M01_MasterData/M01_MasterData_UI_LogicLab_Side.css")
|
||||
cond = 'html:not([data-size="compact"], [data-size="medium"])'
|
||||
assert f"{cond} .m02-main__host" in css and f"{cond} .m01-master__panel" in m01 # 같은 조건
|
||||
@@ -0,0 +1,44 @@
|
||||
"""804-12(요구 695 · 696) — 왼쪽 공식 줄 만약(IF) 식 = 결정된 식 + 근거 한 줄 · 오른쪽 산출근거와 같은 `decide` 한 벌.
|
||||
|
||||
① 1,518 전수 — 만약 든 식 줄마다 결정된 식 값 = 원 식 값 = 셈 답 · 조건을 아는 줄은 만약이 안 남음 · 근거 = 탄 만약 수
|
||||
② 접기 규칙 — 만약이 낳은 수만 접음(-0 · 0 ÷ 100 · 1 - 0) · 「a - -x」 → 「a + x」 · 만약 없는 조각 그대로
|
||||
③ 화면 — 결정된 식을 고치면 그 글로 굳음(만약 사라짐) · 저장본에 만약이 있던 줄 = 「옵션 규칙이 풀렸음」 표지 · 규칙 보기 단추 없음
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from resources.tester.test_804_2_line_stack import _ts, world # noqa: F401 — 같은 셈 바탕
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_결정된_식_전수_셈_같음(world) -> None: # noqa: F811
|
||||
keys = sorted(world["cases"])
|
||||
got = dict(zip(keys, _ts("결정", [world["cases"][k] for k in keys])))
|
||||
lines = [(k, x) for k, xs in got.items() for x in xs]
|
||||
assert len({k for k, _ in lines}) > 350 and len(lines) > 600 # 388 문서 · 663 줄 언저리
|
||||
bad = [(k, x["기호"]) for k, x in lines if not (x["같음"] and x["답"])]
|
||||
assert not bad, bad[:10]
|
||||
left = [(k, x["기호"], x["글"]) for k, x in lines if x["남은만약"]]
|
||||
assert len(left) < len(lines) * 0.05, left[:10] # 조건을 모르는 줄만(빈 값 등)
|
||||
assert sum(x["근거"] for _, x in lines) >= len(lines) - len(left)
|
||||
gc = {x["기호"]: x["글"] for x in got["GC001009"]}
|
||||
assert (
|
||||
gc["Q_시공"] == "Q_시공^표" and gc["Q_그물막인부"] == "0"
|
||||
) # 기본 옵션 = 감률 0 · 할증 없음
|
||||
assert "만약" not in "".join(x["글"] for x in got["GC000211"])
|
||||
|
||||
|
||||
def test_화면_결정된_식_근거_굳음_표지() -> None:
|
||||
rows = (ROOT / "A00_Common/line_stack_rows.ts").read_text(encoding="utf-8")
|
||||
assert "const expr = iffy ? textOf(decide(tree!, vals)) : raw;" in rows
|
||||
assert 'createText(lt("If_Freed"), "warning")' in rows
|
||||
tex = (ROOT / "A00_Common/line_stack_tex.ts").read_text(encoding="utf-8")
|
||||
assert "return tex(decide(n, c.vals), { ...c, decided: true });" in tex # 오른쪽 = 같은 decide
|
||||
assert "takenOf" not in tex and "leafNum" not in tex
|
||||
for rel in ("A00_Common/line_stack_text.ts", "A00_Common/line_stack_rows.ts"):
|
||||
assert "규칙 보기" not in (ROOT / rel).read_text(encoding="utf-8")
|
||||
screen = (ROOT / "A00_Common/line_stack_screen.ts").read_text(encoding="utf-8")
|
||||
assert "before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined," in screen
|
||||
@@ -144,7 +144,7 @@ def test_머리_단추_다섯_저장_한_번() -> None:
|
||||
assert "await loading;" in _src("A00_Common/line_stack_screen.ts")
|
||||
assert "await stack.discard();" in lab # [되돌림] = 둘 다
|
||||
assert "n === 0 && !stack.dirty()" in lab
|
||||
for t in ('tl("New")', "copyButton", 'tx("Bar_Delete")', "discardButton", "saveButton"):
|
||||
for t in ('tl("New")', "copyButton", "deleteButton", "discardButton", "saveButton"):
|
||||
assert t in lab.split("buttons: [")[1].split("]")[0]
|
||||
text = _src("M01_MasterData/M01_MasterData_UI_Logic_Text.ts")
|
||||
assert 'Bar_Discard: ["되돌림", "Revert"]' in text and "캐시 삭제" not in text
|
||||
@@ -238,3 +238,88 @@ def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
|
||||
assert f"iconStart: {i}" in lab, i
|
||||
css = _src("ui_template/ui_template_button.css")
|
||||
assert ".ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label" in css
|
||||
|
||||
|
||||
def test_단위표_더한_단위_문서_전수() -> None:
|
||||
"""804-9(요구 692-1) — 표준 단위 표 「더한 단위」 21 · kg ↔ ㎏ · cm ↔ ㎝ 하나만 · 수식 문서 항목 줄 단위 전부 표에 있음."""
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Router as df
|
||||
|
||||
table = df.get_standards()["unit"]
|
||||
same = table["같은 단위"]
|
||||
assert same == {"kg": "㎏", "cm": "㎝"}
|
||||
added = table["더한 단위"]
|
||||
assert len(added) == len(set(added)) == 21
|
||||
units = {
|
||||
same.get(u, u) for r in table["줄"] for u in (r["규격 단위"], r["단위수량 단위"]) if u
|
||||
} | {same.get(u, u) for u in added}
|
||||
assert "kg" not in units and "cm" not in units and {"㎏", "㎝"} <= units
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def walk(o) -> None:
|
||||
if isinstance(o, dict):
|
||||
if "비목" in o and "단위" in o:
|
||||
lines.append(str(o["단위"] or ""))
|
||||
for v in o.values():
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
walk(v)
|
||||
|
||||
for f in (ROOT / "resources/master_data/수식문서").glob("*.json"):
|
||||
walk(json.loads(f.read_text(encoding="utf-8")))
|
||||
assert len(lines) > 5000
|
||||
missing = {u for u in lines if u and same.get(u, u) not in units}
|
||||
assert not missing, missing
|
||||
pick = _src("M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts")
|
||||
assert (
|
||||
'...(table["더한 단위"] ?? []),' in pick and '(u && table["같은 단위"]?.[u]) || u' in pick
|
||||
)
|
||||
|
||||
|
||||
def test_표값_직접_침_별칭_그대로() -> None:
|
||||
"""804-9(요구 692-1) — 표 값 칸에 숫자 · 식을 쳐도 화면 이름(별칭) 그대로 · 꼬리표 「직접 입력」 · 서버 셈 = 표 정보 없는 숫자와 같음."""
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from resources.tester.test_804_2_line_stack import _ts, mr
|
||||
|
||||
docs = dc.folder()
|
||||
keys = sorted(
|
||||
k for k, d in docs.items() if any((v.get("위") == "표") for v in d.get("변수") or [])
|
||||
)
|
||||
got = dict(zip(keys, _ts("직접", [{"문서": docs[k]} for k in keys])))
|
||||
named = 0
|
||||
for k, g in got.items():
|
||||
assert g["전"] == g["숫자"] == g["식"], k
|
||||
assert set(g["갈래"]) <= {"숫자"}, k
|
||||
named += len(g["전"])
|
||||
assert named > 500
|
||||
links = dc.ml.Links(dc.skc.whole_(), docs)
|
||||
calc = lambda d: dc.mc.calc(d, (r := mr.Rec(links), r.prefetch(d))[0]) # noqa: E731
|
||||
docs_sum = {k: calc(docs[k]).get("계") for k in ("GF000099", "GF000226", "GC000211")}
|
||||
for k in ("GF000099", "GF000226", "GC000211"):
|
||||
typed = got[k]["문서"]
|
||||
plain = json.loads(json.dumps(typed))
|
||||
for v in plain["변수"]:
|
||||
link = v.get("연결") or {}
|
||||
if link.get("갈래") == "숫자":
|
||||
v["연결"] = {"갈래": "숫자", "값": link.get("값")}
|
||||
a, b = calc(typed), calc(plain)
|
||||
assert [a.get(c) for c in ("노무비", "재료비", "경비", "계")] == [
|
||||
b.get(c) for c in ("노무비", "재료비", "경비", "계")
|
||||
], k
|
||||
assert a.get("계") and a.get("계") != docs_sum.get(k), (k, a.get("계"))
|
||||
rows = _src("A00_Common/line_stack_rows.ts")
|
||||
assert 'link?.갈래 === "숫자" && link.표 && !expr' in rows
|
||||
assert "const from = v?.연결?.표 ?" in _src("common_util/common_util_line_stack.ts")
|
||||
|
||||
|
||||
def test_공식_묶음_격자_긴_이름_줄임() -> None:
|
||||
"""804-9(요구 692-3) — 공식 묶음 = 폭만큼 여러 열 · 머리 · 식 글 · 고치는 줄 = 열 합침 · 긴 이름 · 표 단추 · 꼬리표 = 줄임표 + 마우스 글."""
|
||||
css = _src("A00_Common/line_stack.css")
|
||||
assert "grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));" in css
|
||||
assert ".lstk-line.k-공식 > .lstk-frow:is(.is-open, :has(> .lstk-expr))" in css
|
||||
assert ".ui-btn[data-clip] > .ui-btn__label" in _src("ui_template/ui_template_button.css")
|
||||
assert "clip?: boolean;" in _src("ui_template/ui_template_button.ts")
|
||||
rows = _src("A00_Common/line_stack_rows.ts")
|
||||
assert "clip: true," in rows and "attrs: { title: text }" in rows
|
||||
assert "name.title = `${show(id)} — ${name.title}`;" in rows
|
||||
|
||||
@@ -315,7 +315,7 @@ def test_구조물_조합_제목_줄_새_항목():
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main
|
||||
assert re.search(
|
||||
r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*save,"
|
||||
r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*actions\.undo,\s*save,"
|
||||
r"\s*\.\.\.\(extras\.newItem \? \[extras\.newItem\] : \[\]\),?\s*\]",
|
||||
main,
|
||||
)
|
||||
|
||||
@@ -198,3 +198,14 @@
|
||||
.ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 긴 글 줄임(clip) — 칸 폭 안에서 줄임표 · 전체 글은 title */
|
||||
.ui-btn[data-clip] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ui-btn[data-clip] > .ui-btn__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface ButtonOptions {
|
||||
type?: "button" | "submit" | "reset";
|
||||
/** 앞쪽 아이콘 요소 (선택) — 이름이면 단 크기 아이콘 */
|
||||
iconStart?: HTMLElement | IconName;
|
||||
/** 긴 글 = 칸 폭 안에서 줄임표 · 마우스 글에 전체(`data-clip`) */
|
||||
clip?: boolean;
|
||||
}
|
||||
|
||||
export function createButton(opts: ButtonOptions): HTMLButtonElement {
|
||||
@@ -79,6 +81,10 @@ export function createButton(opts: ButtonOptions): HTMLButtonElement {
|
||||
btn.append(mark);
|
||||
}
|
||||
btn.append(el("span", { className: "ui-btn__label", text: opts.label }));
|
||||
if (opts.clip) {
|
||||
btn.dataset.clip = "";
|
||||
btn.title = opts.label;
|
||||
}
|
||||
if (opts.disabled) btn.disabled = true;
|
||||
if (opts.onClick) btn.addEventListener("click", opts.onClick);
|
||||
return btn;
|
||||
|
||||
Reference in New Issue
Block a user