diff --git a/M01_MasterData/M01_MasterData_Equation.py b/M01_MasterData/M01_MasterData_Equation.py index c64c33060..7b390fdab 100644 --- a/M01_MasterData/M01_MasterData_Equation.py +++ b/M01_MasterData/M01_MasterData_Equation.py @@ -44,23 +44,9 @@ def _typed(eq: dict, row: dict) -> dict: for line in eq.get("줄") or []: if line.get("식"): line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row) - _atoms(line.get("이름식"), lambda raw: me.keyboard_of(fx.to_name(raw, row), row)) return eq -def _atoms(tree, word) -> None: - """식 속 표 찾기 · 요소 · 로직 부름 마디 `입력글` = 키보드 글(801 그 자리 고침 · 블록 속 나무 빼고).""" - if isinstance(tree, dict): - if tree.get("식") and (tree.get("꼴") == "블록" or tree.get("갈래") in ("찾기", "요소")): - tree["입력글"] = word(str(tree["식"])) - for k, v in tree.items(): - if k != "나무": - _atoms(v, word) - elif isinstance(tree, list): - for v in tree: - _atoms(v, word) - - def export_row(whole, row: dict, inputs: dict | None = None) -> dict: """로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈.""" eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs)) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index e1a6c08fc..348cc5e5a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -4,9 +4,7 @@ * 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1) * 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만) * 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음) - * 고침(801) = 보이는 조판 식 그 자리에서(`ui_template_equation_edit` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭) - * · 값 식 숫자 = 그 변수 값(`valueEdit` — 입력 = 옵션 값 · 중간 = 그 줄 식 · 수 · 표 찾기 · 요소 = 이 식의 그 자리 수) - * · 기호를 누르면 정의 줄이 열림(이름 · 기호 · 첨자 · 계산 변수는 연결 바꿈) + * 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭) * · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서 * · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) @@ -28,6 +26,7 @@ import { type EqNode, type EqTree, } from "@ui/ui_template_equation"; +import { lineId } from "@ui/ui_template_equation_line"; import { el, hideLoadingOverlay, @@ -39,8 +38,7 @@ import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; -import type { FormulaVerdict } from "../A00_Common/sheet_formula_cell"; -import { editEq } from "@ui/ui_template_equation_edit"; +import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell"; import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values"; import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api"; @@ -52,13 +50,7 @@ import { } from "./M01_MasterData_UI_LogicLab_EquationDefs"; import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; -import { - formulaEdit, - inputsOf, - unknownOf, - valueEdit, - type EqEdit, -} from "./logic/M01_Logic_Equation_Model"; +import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model"; import "./M01_MasterData_UI_LogicLab_Equation.css"; const TEXT = { @@ -78,8 +70,8 @@ const TEXT = { Options: ["옵션", "Options"], Wait: ["수식을 불러오는 중", "Loading equation"], Hint: [ - "식을 눌러 그 자리 고침(+ − × ÷ / ( ) _ ^ · Enter 넣음 · Esc 버림) · 기호 = 연결 · 숫자 = 값", - "Click a formula to edit in place · symbol = link · number = value", + "식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )", + "Click a formula to edit · keyboard + − × ÷ / ( )", ], Apply: ["연결 적용 · 식 넣기", "Apply links and formula"], Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"], @@ -179,7 +171,6 @@ export function createLabEquation(opt: LabEquationOptions) { let before: Record | null = null; let why = ""; let editingLine: string | null = null; - let editAt: { x: number; y: number } | null = null; let defEditing: string | null = null; let pending: PendingLink | null = null; let defsBox: HTMLElement = el("div"); @@ -187,6 +178,7 @@ export function createLabEquation(opt: LabEquationOptions) { let timer = 0; let paintedBlocked = false; const open = new Set(); + const drafts = new Map(); const canEdit = (): boolean => opt.admin && !opt.blocked(); const page = opt.page; const vals = (): Record => page?.values() ?? inputs; @@ -415,6 +407,7 @@ export function createLabEquation(opt: LabEquationOptions) { if (missing) return; const bad = await commit([...list, formulaEdit(p.line, p.text)]); if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · "))); + drafts.delete(lineId(p.line)); [pending, editingLine] = [null, null]; paint(); } @@ -440,66 +433,43 @@ export function createLabEquation(opt: LabEquationOptions) { }; }; - /** 수식 영역 — 그린 식 그 자리 고침 · 칠 때마다 미리 셈 · Enter = 고침 목록에 굳힘 */ - function editor(line: EqLine, _id: string, host: HTMLElement): void { - const say = el("span", { className: "ui-eq__bad" }); - host.closest(".ui-eq__name")?.after(say); + /** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */ + function editor(line: EqLine, id: string): HTMLElement { + const shown = line.입력글 ?? line.식; const close = (): void => { [editingLine, pending] = [null, null]; paint(); }; - const show = (ok: boolean, list: string[]): void => { - host.dataset.bad = ok ? "" : "1"; - say.className = ok ? "ui-eq__note" : "ui-eq__bad"; - say.textContent = list.join(" · "); - }; - let mine: AbortController | null = null; - editEq(host, { - at: editAt, - onInput: (text) => { - mine?.abort(); - const me = (mine = new AbortController()); - check(line)(text, me.signal) - .then((v) => me === mine && show(v.되나, (v.되나 ? v.알림 : v.까닭) ?? [])) - .catch(() => undefined); + const cell = createFormulaCell({ + key: id, + spot: id, + shown, + origin: shown, + drafts, + check: check(line), + put: (text) => { + if (text === null) return pending ? undefined : close(); + void commit([formulaEdit(line, text)]).then((bad) => { + if (bad.length) showToast(bad.join(" · "), "error"); + else close(); + }); }, - onCommit: (text) => { - if (pending) return void applyPending(); - void commit([formulaEdit(line, text)]).then((bad) => - bad.length ? show(false, bad) : close(), - ); - }, - onCancel: () => (pending ? undefined : close()), + className: "m01-eq__fx", }); + const input = cell.querySelector("input"); + // 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로 + cell.addEventListener("focusout", () => + window.setTimeout(() => { + if (editingLine === id && !pending && !cell.contains(document.activeElement)) { + if ((input?.value.trim() ?? shown) === shown) close(); + } + }, 120), + ); + queueMicrotask(() => input?.focus()); + return cell; } - /** 값 숫자 고침 — 입력 = 옵션 값 · 그 밖 = 고침 목록 */ - function onValue(line: EqLine, leaf: EqNode, text: string): void { - const got = valueEdit(line, leaf, text); - if (typeof got === "string") return showToast(got, "error"); - if ("input" in got) { - if (page) return page.setValue(got.input, text); - inputs[got.input] = text; - drawOptions(); - return askAgain(); - } - void commit(got.edits).then((bad) => bad.length && showToast(bad.join(" · "), "error")); - } - - /** 기호를 누름 — 정의 줄로 · 고칠 수 있으면 그 줄 고침 열림 */ - function showVar(node: EqNode): void { - const sign = defSign({ - 기호: node.기호 || node.열쇠 || "", - 위: typeof node.위 === "string" ? node.위 : "", - 아래: typeof node.아래 === "string" ? node.아래 : "", - }); - const def = eq?.정의?.find((d) => defSign(d) === sign); - if (canEdit() && def?.고침 && defEditing !== sign) { - defEditing = sign; - drawDefs(); - } - flashEqDef(defsBox, node); - } + const showDef = (node: EqNode): void => flashEqDef(defsBox, node); // ── 그림 ────────────────────────────────────────────────────────── function drawBar(): void { @@ -559,16 +529,15 @@ export function createLabEquation(opt: LabEquationOptions) { path: "", open, resultUnit: eq.결과단위, - onVar: showVar, // 기호 = 정의 줄(연결) · 그 밖 식 자리 = 그 자리 고침 + onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함) editing: editingLine, onEdit: edit - ? (_line, id, at) => { - [editingLine, editAt] = [id, at]; + ? (_line, id) => { + editingLine = id; paint(); } : undefined, editor: edit ? editor : undefined, - onValue: edit ? onValue : undefined, after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null), }), ...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계 @@ -607,6 +576,7 @@ export function createLabEquation(opt: LabEquationOptions) { async function discard(): Promise { edits = []; [editingLine, defEditing, pending] = [null, null, null]; + drafts.clear(); persist(); await refresh(); drawOptions(); @@ -625,6 +595,7 @@ export function createLabEquation(opt: LabEquationOptions) { inputs = {}; edits = page ? page.edits() : (loadCache()[nextKey] ?? []); open.clear(); + drafts.clear(); paint(); drawOptions(); void refresh().then(drawOptions); diff --git a/M01_MasterData/logic/M01_Logic_Equation_Model.ts b/M01_MasterData/logic/M01_Logic_Equation_Model.ts index a8678106b..7df79333f 100644 --- a/M01_MasterData/logic/M01_Logic_Equation_Model.ts +++ b/M01_MasterData/logic/M01_Logic_Equation_Model.ts @@ -2,12 +2,10 @@ * M01_MasterData/logic/M01_Logic_Equation_Model.ts * 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청. * 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦 - * 값 숫자 고침(801 · `valueEdit`) = 입력 → 옵션 값 · 중간 → 그 줄 식 = 그 수 · 수 · 표 찾기 · 요소 → 이 줄 식의 그 자리 = 그 수 * 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만 * ========================================================================== */ -import type { EqLine, EqNode } from "@ui/ui_template_equation"; -import { textOf } from "@ui/ui_template_equation_text"; +import type { EqLine } from "@ui/ui_template_equation"; export interface EqEdit { 할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결"; @@ -123,17 +121,3 @@ export function unknownOf(reasons: string[]): string[] { } return []; } - -/** 값 식 숫자 고침 → 옵션 값(입력) | 고침 목록 | 까닭 글 */ -export function valueEdit( - line: EqLine, - leaf: EqNode, - text: string, -): { input: string } | { edits: EqEdit[] } | string { - if (!/^-?\d+(\.\d+)?$/.test(text)) return "값은 수로 적을 것"; - if (leaf.꼴 === "변수" && leaf.갈래 === "입력") return { input: leaf.열쇠 ?? "" }; - if (leaf.꼴 === "변수" && leaf.갈래 === "중간") - return { edits: [{ 할일: "식", 자리: `중간:${leaf.열쇠 ?? ""}`, 식: text }] }; - const tree = line.갈래 === "호표" ? line.이름식?.우 : line.이름식; // 호표 식 = 수량 쪽 - return { edits: [formulaEdit(line, textOf(tree, new Map([[leaf, text]])))] }; -} diff --git a/resources/master_data/scripts/master_equation.py b/resources/master_data/scripts/master_equation.py index 8b6c87932..0c5184e18 100644 --- a/resources/master_data/scripts/master_equation.py +++ b/resources/master_data/scripts/master_equation.py @@ -28,7 +28,6 @@ from pathlib import Path import master_auto as ma import master_cut as mcut -import master_equation_sign as msign import master_equation_view as view import master_formula as mf import master_material as mm @@ -346,8 +345,7 @@ def values(tree: dict) -> dict: if kind in ("변수", "블록"): value = tree.get("값") got = _num(value) if value is not None else {"꼴": "글", "글": "?"} - unit = tree.get("단위") or tree.get("결과단위") or "" # 값 식 숫자 단위(801-4) - return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록", "단위": unit} + return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록"} if kind == "조건": if tree.get("걸림"): return values(tree["참" if tree["걸림"] == 1 else "거짓"]) @@ -464,7 +462,7 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: total = {k: cut.get(k) for k in mf.BUNDLE} if depth == 0: sub = {"소계": subtotals(row, cut, total)} - out = msym.apply( # 기호 먼저(800-1) → 한 마디 줄 · 로직 부름 기호(801) → 정의(800-2) + out = msym.apply( # 기호 먼저(800-1) → 정의는 기호 씌운 줄로(800-2) { "열쇠": row.get("키"), "이름": row.get("이름") or "", @@ -476,7 +474,6 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: **sub, } ) - msign.settle(out) if depth == 0: out["정의"] = view.definitions(out_lines, row, out["변수표"], _unused(row)) return _clean(out) diff --git a/resources/master_data/scripts/master_equation_edit.py b/resources/master_data/scripts/master_equation_edit.py index 47f5a34bd..2b35e6fe9 100644 --- a/resources/master_data/scripts/master_equation_edit.py +++ b/resources/master_data/scripts/master_equation_edit.py @@ -414,13 +414,7 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str], _show(new, key, ed, why) elif job == "연결": add, bad = link.plan(master, ed) - mid = key in {str(s.get("이름")) for s in new.get("중간") or []} - if add and mid and add.get("식"): # 있는 계산 변수 연결 바꿈(801) = 그 줄 식 - _set_formula(new, {"자리": f"중간:{key}", "식": add["식"]}, why, spots, words) - elif add and mid: - why.append(f"「{key}」 계산 변수는 입력으로 못 바꿈 — 표 · 요소 · 로직 · 상수") - else: - _add(new, add, why, spots, words) if add else why.append(bad) + _add(new, add, why, spots, words) if add else why.append(bad) else: why.append(f"모르는 할일 「{job}」") except (mf.FormulaError, KeyError, TypeError, ValueError) as e: diff --git a/resources/master_data/scripts/master_equation_view.py b/resources/master_data/scripts/master_equation_view.py index b9f370e41..3027062f3 100644 --- a/resources/master_data/scripts/master_equation_view.py +++ b/resources/master_data/scripts/master_equation_view.py @@ -76,8 +76,8 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[ leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")} elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식 leaf[str(line.get("열쇠"))] = tree - found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + ( - [] if line.get("한마디") else visible(tree, []) # 한 마디 줄 = 줄 변수 하나(801) + found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible( + tree, [] ) for node in found: key = str(node.get("열쇠") or "") @@ -97,7 +97,7 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[ if kind == "블록" and not leaf.get(key): link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함 name = (look.get("이름") or key) if mine else look.get("이름") or "" - name = node.get("칸") or name if kind == "찾기" and not node.get("칸글") else name + name = node.get("칸") or name if kind == "찾기" else name one = { "열쇠": key if mine else "", "기호": look.get("기호") or name, # 줄 참조도 씌운 기호(master_symbol 한 곳) diff --git a/resources/tester/test_801_1_equation_inline.py b/resources/tester/test_801_1_equation_inline.py deleted file mode 100644 index b7fad9f7c..000000000 --- a/resources/tester/test_801_1_equation_inline.py +++ /dev/null @@ -1,169 +0,0 @@ -# -*- coding: utf-8 -*- -"""PLAN 801-1 · 801-2 · 801-4 — 수식 = 발표 자료 꼴 · 보이는 식 그 자리 고침 (서버 · 글 꼴 TS 를 node 로). - -한 마디 줄 「q = 0.7」(표 칸 글 0) · 로직 부름 = 기호 · 반올림 감싸개 · 안 탄 갈래 안 보임 · -값 식 숫자 단위 · 나무 → 키보드 식(`ui_template_equation_text.ts`) 왕복 = 금액 같음 · -값 고침 = 그 변수 값 · 기호 고침(연결) = 그 줄 식. -""" - -import json -import re -import subprocess -import sys -from pathlib import Path - -import pytest -from fastapi.encoders import decimal_encoder - -ROOT = Path(__file__).resolve().parents[2] -sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")] - -from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402 -from M01_MasterData import M01_MasterData_Store as store # noqa: E402 - -import master_formula as mf # noqa: E402 - -TEXT = ROOT / "ui_template/ui_template_equation_text.ts" -MODEL = ROOT / "M01_MasterData/logic/M01_Logic_Equation_Model.ts" -PLAIN = ("적용", "값", "기준") -KEYS = ("GF000098", "GF000099", "GC000211", "GC001148", "GC000996", "GC000998", "GC000999") -OFF = {"폼타이": "아님", "측압": "3t/㎡"} -needs_node = pytest.mark.skipif( - not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" -) - - -def _read(key, inputs=None): - return eq.read(key, inputs or {})["수식"] - - -def _ts(call: str, data) -> object: - """글 꼴 TS(+ 모델 `valueEdit`)를 node 로 — import 줄 걷고 이어 붙임.""" - src = "\n".join( - re.sub(r"^import[^;]*;\n", "", p.read_text(encoding="utf-8"), flags=re.M) - for p in (TEXT, MODEL) - ) - script = f"{src}\nconst DATA = {json.dumps(data, ensure_ascii=False, default=decimal_encoder)};\nconsole.log(JSON.stringify({call}));" - got = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript"], - input=script, capture_output=True, text=True, encoding="utf-8", timeout=120, - ) # fmt: skip - assert got.returncode == 0, got.stderr[-2000:] - return json.loads(got.stdout) - - -def _sign(n): - return (n.get("기호"), n.get("아래") or "", n.get("위") or "") - - -def _nodes(tree, out): - if isinstance(tree, dict): - if tree.get("꼴") in ("변수", "블록"): - out.append(tree) - for k, v in tree.items(): - if k != "나무": - _nodes(v, out) - elif isinstance(tree, list): - for v in tree: - _nodes(v, out) - return out - - -def test_한마디_줄_표_칸_글_0(): - lines = {x["열쇠"]: x for x in _read("GF000098")["줄"]} - for key in ("q", "K", "f", "E", "Cm"): - line = lines[key] - assert line["한마디"] and _sign(line["이름식"]) == _sign(line["결과"]) - assert "적용" not in json.dumps(_sign(line["이름식"]), ensure_ascii=False) - defs = _read("GF000098")["정의"] - assert [d["기호"] for d in defs] == ["q", "K", "f", "E", "Cm", "Q"] # 표 찾기 따로 줄 없음 - - -def test_로직_부름_기호_뜻없는_칸_글_0(): - got = _read("GF000099") - lines = {x["열쇠"]: x for x in got["줄"]} - assert lines["작업량"]["한마디"] and lines["작업량"]["이름식"]["꼴"] == "블록" - assert _sign(lines["작업량"]["이름식"]) == _sign(lines["작업량"]["결과"]) - for key in KEYS: - for node in _nodes([x.get("이름식") for x in _read(key, OFF)["줄"]], []): - text = "".join(str(x) for x in _sign(node)) - assert not re.search(r"[가-힣]", node.get("기호") or ""), (key, node.get("기호")) - if node.get("갈래") == "찾기": - assert not any(w in text for w in PLAIN), (key, text) - - -def test_값_식_단위(): - res = next(x for x in _read("GF000098")["줄"] if x["갈래"] == "결과") - units = [n.get("단위") for n in _nodes(res["값식"], []) + [res["값식"]]] - flat = json.dumps(res["값식"], ensure_ascii=False, default=str) - assert '"단위": "초"' in flat or "초" in units - - -@needs_node -def test_반올림_감싸개_안_탄_갈래_안_보임(): - res = next(x for x in _read("GF000098")["줄"] if x["갈래"] == "결과") - assert res["이름식"]["꼴"] == "함수" and res["이름식"]["이름"] == "반올림" # 셈 그대로 - leaves = _ts("leavesOf(DATA).map((n) => n.글 ?? n.기호)", res["값식"]) - assert leaves[-1] != "3" and len(leaves) == 6 # 3,600 · q · K · f · E · Cm — 자리 3 없음 - line = next(x for x in _read("GC000211", OFF)["줄"] if x["열쇠"].startswith("폼타이")) - shown = _ts("leavesOf(DATA).map((n) => n.글 ?? n.기호)", line["이름식"]) - assert "12.7㎜" not in json.dumps(shown, ensure_ascii=False) # 안 탄 만약 조건 · 갈래 없음 - - -@needs_node -def test_나무_글_왕복_금액_같음(): - for key in KEYS: - got = _read(key, OFF) - lines = [x for x in got["줄"] if x.get("이름식")] - texts = _ts( - "DATA.map((l) => textOf(l.갈래 === '호표' ? l.이름식.우 : l.이름식))", - lines, - ) - edits = [ - {"할일": "식", "자리": _spot(line), "식": text} for line, text in zip(lines, texts) - ] - back = eq.preview(key, edits, OFF) - assert back["되나"], (key, back["까닭"]) - # 같은 엔진 식(마디 나무) = 같은 셈 — 금액은 고친 중간 줄 표시 자리(요구 658)가 붙어 따로 안 봄 - old = store.loaded()[1].logic(key) - for part, field in (("중간", "식"), ("호표", "수량"), ("덧줄", "식")): - for a, b in zip(old.get(part) or [], back["줄"].get(part) or []): - assert mf.parse(str(a[field])) == mf.parse(str(b[field])), (key, a, b) - if "결과" in old: - assert mf.parse(old["결과"]["식"]) == mf.parse(back["줄"]["결과"]["식"]) - - -def _spot(line): - kind = line["갈래"] - if kind in ("호표", "덧줄"): - return f"{kind}:{line.get('차례', 0)}" - return "결과" if kind == "결과" else f"중간:{line['열쇠']}" - - -@needs_node -def test_값_고침_그_변수_값(): - lines = _read("GF000098")["줄"] - q = next(x for x in lines if x["열쇠"] == "q") - res = next(x for x in lines if x["갈래"] == "결과") - got = _ts("valueEdit(DATA.q, DATA.q.결과, '0.8')", {"q": q}) - assert got == {"edits": [{"할일": "식", "자리": "중간:q", "식": "0.8"}]} - after = eq.preview("GF000098", got["edits"], {}) - assert float(after["후"]["결과"]) == pytest.approx(66.528) - lit = _ts( - "valueEdit(DATA, leavesOf(DATA.이름식)[0], '3601').edits[0]", - res, - ) # 결과 식의 3,600 → 3,601 · 반올림 감싸개 그대로 - assert lit["자리"] == "결과" and lit["식"].startswith("반올림(3601 ") - after = eq.preview("GF000098", [lit], {}) - assert after["되나"] and after["후"]["결과"] != after["전"]["결과"] - - -def test_기호_고침_연결_바꿈(): - ed = {"할일": "연결", "열쇠": "q", "갈래": "상수", "값": "0.8"} - got = eq.preview("GF000098", [ed], {}) - assert got["되나"] and float(got["후"]["결과"]) == pytest.approx(66.528) - q = next(d for d in got["수식"]["정의"] if d["기호"] == "q") - assert q["연결"] == ["상수 — 0.8"] - bad = eq.preview("GF000098", [{"할일": "연결", "열쇠": "q", "갈래": "입력"}], {}) - assert not bad["되나"] diff --git a/ui_template/ui_template_equation.css b/ui_template/ui_template_equation.css index 5f8ab5ae4..eafb95fb3 100644 --- a/ui_template/ui_template_equation.css +++ b/ui_template/ui_template_equation.css @@ -171,13 +171,15 @@ min-width: 0; } -/* 네 마디 — 결과명 = 기호 식 = 값 식 = 결과 값(801 — 박스 · 테두리 없는 조판 한 줄) */ +/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */ .ui-eq__box { display: inline-flex; align-items: center; gap: var(--spacing-4); - padding: 2px var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border-strong); border-radius: var(--radius-sm); + background: var(--color-surface-raised); max-width: 100%; min-width: 0; } @@ -204,43 +206,24 @@ font-size: var(--text-caption); } -/* 바로 고침(801) — 보이는 식 그 자리(커서) · 값 숫자 그 자리 */ +/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */ .ui-eq__name.is-edit { cursor: text; } -.ui-eq__name.is-edit:hover, +.ui-eq__name.is-edit:hover { + border-color: var(--color-accent); +} + .ui-eq__name.is-open { - background: var(--color-accent-soft); + flex: 1 1 24rem; + align-items: flex-start; + border-color: var(--color-accent); } -.ui-eq__edit { - display: inline-flex; - align-items: center; - gap: 0 var(--spacing-4); - min-width: 1ch; - outline: none; - caret-color: var(--color-accent); -} - -.ui-eq__edit[data-bad="1"] { - text-decoration: underline wavy var(--color-danger); -} - -.ui-eq__note { - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.ui-eq .is-value { - cursor: pointer; - border-radius: var(--radius-sm); -} - -.ui-eq .is-value:hover, -.ui-eq .is-value[data-editing] { - background: var(--color-accent-soft); - outline: none; +.ui-eq__name.is-open > * { + flex: 1 1 auto; + min-width: 0; } /* 하위 로직 블록 — 접힘 */ diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts index be529c60a..b438eef97 100644 --- a/ui_template/ui_template_equation.ts +++ b/ui_template/ui_template_equation.ts @@ -6,14 +6,10 @@ * 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄 * 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만 * 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts` - * 801 = 박스 없는 조판 · 만약 = 탄 갈래만 · 반올림 감싸개 안 보임 · 로직 부름 = 기호 · 값 식 숫자 뒤 단위 · - * 바로 고침 표지(원자 `data-tok` · 감싸개 `data-pre` · `data-post` — 글 꼴 바꿈 = `ui_template_equation_text.ts` · - * `ui_template_equation_edit.ts`) * 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠) * ========================================================================== */ import "./ui_template_equation.css"; -import { HIDDEN, taken, textOf } from "./ui_template_equation_text"; /** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */ export interface EqNode { @@ -29,8 +25,6 @@ export interface EqNode { 단위?: string; 갈래?: string; 식?: string; - /** 표 찾기 · 요소 · 로직 부름 키보드 글(801 · 서버 `_atoms`) */ - 입력글?: string; 칸?: string | null; 표?: string; 표이름?: string; @@ -148,8 +142,6 @@ export interface EqLine { 원래?: unknown; 지금?: unknown; 뺌?: boolean; - /** 801 — 식이 표 찾기 · 요소 · 로직 부름 한 마디뿐(「q = 0.7」 · 그 마디 기호 = 줄 기호) */ - 한마디?: boolean; } export interface EqHandlers { @@ -219,7 +211,7 @@ export function plainEq(node: EqNode | undefined): string { return sign + low + up; } case "블록": - return node.기호 ? plainEq({ ...node, 꼴: "변수" }) : node.이름 || node.열쇠 || ""; + return node.이름 || node.열쇠 || ""; case "연산": return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`; case "분수": @@ -230,13 +222,9 @@ export function plainEq(node: EqNode | undefined): string { return `${plainEq(node.안)}%`; case "음": return `−${plainEq(node.안)}`; - case "조건": { - const pick = taken(node); - if (pick) return plainEq(pick); + case "조건": return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`; - } case "함수": - if (HIDDEN.has(node.이름 ?? "")) return plainEq(node.인자?.[0]); return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`; default: return ""; @@ -246,7 +234,6 @@ export function plainEq(node: EqNode | undefined): string { /** 변수 한 개 — 눌리면 `onVar` */ function variable(node: EqNode, h: EqHandlers): HTMLElement { const box = span("ui-eq__var", symbolOf(node)); - box.dataset.tok = textOf(node); box.dataset.kind = node.갈래 ?? ""; box.dataset.eqVar = node.열쇠 ?? ""; const value = eqText(node.값); @@ -269,17 +256,6 @@ function variable(node: EqNode, h: EqHandlers): HTMLElement { function block(node: EqNode, h: EqHandlers): HTMLElement { const at = (h.blocks ?? []).push(node); - if (node.기호) { - // 801 — 로직 부름 = 기호(이름은 정의 영역 · 펼침 머리) · 누르면 펼침 - const sym = variable( - { ...node, 갈래: "블록" }, - { onVar: h.onBlock ? () => h.onBlock?.(node) : undefined }, - ); - sym.dataset.tok = textOf(node); - sym.dataset.eqBlock = node.열쇠 ?? ""; - sym.title = `${node.이름 ?? ""} ${eqText(node.값)}`.trim(); - return sym; - } const value = eqText(node.값); const chip = span( "ui-eq__block", @@ -303,24 +279,14 @@ function block(node: EqNode, h: EqHandlers): HTMLElement { return chip; } -/** 고침 표지 — 감싸개 앞 · 뒤 글(그림에 안 보이는 식 조각) */ -const wrapped = (pre: string, post: string, kid: HTMLElement): HTMLElement => { - const box = span("ui-eq__expr", kid); - box.dataset.pre = pre; - box.dataset.post = post; - return box; -}; - -/** 수식 나무 → 인라인 요소(`top` = 줄 맨 위 — 숨긴 감싸개 안 연산에 괄호 안 씀) */ -export function renderEq(node: EqNode | undefined, h: EqHandlers = {}, top = true): HTMLElement { +/** 수식 나무 → 인라인 요소 */ +export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement { if (!node) return span("ui-eq__expr"); const sub = (child: EqNode | string | undefined): HTMLElement => - typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h, false); + typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h); switch (node.꼴) { - case "수": { - const num = span("ui-eq__num", node.글 ?? ""); - return node.단위 ? span("ui-eq__expr", num, span("ui-eq__unit", node.단위)) : num; - } + case "수": + return span("ui-eq__num", node.글 ?? ""); case "글": return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? "")); case "변수": @@ -342,24 +308,17 @@ export function renderEq(node: EqNode | undefined, h: EqHandlers = {}, top = tru } case "괄호": return span("ui-eq__paren", span("ui-eq__inner", sub(node.안))); - case "퍼센트": { - const pct = span("ui-eq__op", "%"); - pct.dataset.tok = "/ 100"; - return span("ui-eq__expr", sub(node.안), pct); - } + case "퍼센트": + return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%")); case "음": return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안)); case "조건": { - if (taken(node)) { - // 801-4 — 탄 갈래만(안 탄 갈래 · 조건은 안 보임 · 고칠 때 구조는 감싸개 글로 지킴) - const [c, yes, no] = [node.조건, node.참, node.거짓].map((x) => textOf(x)); - return node.걸림 === 1 - ? wrapped(`만약(${c}, `, `, ${no})`, sub(node.참)) - : wrapped(`만약(${c}, ${yes}, `, ")", sub(node.거짓)); - } + const pick = node.걸림 ?? 0; const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건)); chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`; - const all = span( + if (pick === 1 || pick === 2) + return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓)); + return span( "ui-eq__expr", chip, span("ui-eq__op", "참"), @@ -367,20 +326,8 @@ export function renderEq(node: EqNode | undefined, h: EqHandlers = {}, top = tru span("ui-eq__op", "거짓"), sub(node.거짓), ); - all.dataset.tok = textOf(node); - return all; } case "함수": { - if (HIDDEN.has(node.이름 ?? "") && node.인자?.length) { - // 801-2 — 반올림 · 자리 감싸개 안 보임(셈 그대로) - const [first, ...rest] = node.인자; - const inner = - first?.꼴 === "연산" && !top - ? span("ui-eq__paren", span("ui-eq__inner", sub(first))) - : sub(first); - const tail = rest.map((x) => `, ${textOf(x)}`).join(""); - return wrapped(`${node.이름}(`, `${tail})`, inner); - } const args = span("ui-eq__expr"); (node.인자 ?? []).forEach((a, i) => args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])), diff --git a/ui_template/ui_template_equation_line.ts b/ui_template/ui_template_equation_line.ts index 35d792ae9..2dec7bf36 100644 --- a/ui_template/ui_template_equation_line.ts +++ b/ui_template/ui_template_equation_line.ts @@ -5,18 +5,13 @@ * 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글) * 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음) * 크기 = 글 `--text-body` · 접힘 단추 22 - * 상태 = 바로 고침(801) — 기호 식을 누르면 그린 식 그대로 고침 자리(`onEdit(line, id, 누른 자리)` → 부르는 쪽이 `editing` 을 정해 - * 다시 그림 · `editor(line, id, host)` 가 그 자리에 고침을 붙임 · 호표는 수량 쪽만) · 값 식 숫자 · 중간 줄 결과 값 = 그 자리 숫자 고침(`onValue`) - * · 펼친 블록 `data-open` - * 801 = 박스 · 테두리 없는 조판 한 줄 · 한 마디 줄(서버 `한마디` — 표 찾기 · 요소 · 로직 부름 하나)은 「q = 0.7」(로직 부름은 아래 펼침 그대로) - * 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor, onValue })` + * 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open` + * 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })` * 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음) * ========================================================================== */ import { createBadge } from "./ui_template_badge"; import { createButton } from "./ui_template_button"; -import { editNumber } from "./ui_template_equation_edit"; -import { leavesOf } from "./ui_template_equation_text"; import { eqText, plainEq, @@ -36,12 +31,10 @@ export interface EqLineOptions { onVar?: (node: EqNode) => void; /** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */ editing?: string | null; - /** 기호 식을 누름(없으면 고칠 수 없음) — 누른 자리 */ - onEdit?: (line: EqLine, id: string, at: { x: number; y: number }) => void; - /** 고치는 줄 — 그린 식(`host`)에 고침을 붙임 */ - editor?: (line: EqLine, id: string, host: HTMLElement) => void; - /** 값 숫자를 고침 — `leaf` = 기호 식의 그 마디(수 · 변수) · 중간 줄 결과 값이면 줄 머리 */ - onValue?: (line: EqLine, leaf: EqNode, text: string) => void; + /** 이름 식 칸을 누름(없으면 고칠 수 없음) */ + onEdit?: (line: EqLine, id: string) => void; + /** 고치는 줄의 식 칸 */ + editor?: (line: EqLine, id: string) => HTMLElement; /** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */ after?: (line: EqLine) => HTMLElement | null; /** 결과 줄 값 뒤 단위 */ @@ -93,7 +86,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { box.dataset.eqBlockPanel = node.열쇠 ?? ""; box.dataset.blockId = id; const head = createButton({ - label: `${node.기호 ? "" : `${String.fromCodePoint(0x245f + at)} `}${node.이름 || node.열쇠 || ""}`, + label: `${String.fromCodePoint(0x245f + at)} ${node.이름 || node.열쇠 || ""}`, variant: "ghost", size: 22, iconStart: "chevronRight", @@ -117,8 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement { body.hidden = !on; }; body.hidden = true; - const sign = node.기호 ? [symbolOf(node), span("ui-eq__op", ":")] : []; - box.append(div("ui-eq__block-row", ...sign, head, bundle(node)), body); + box.append(div("ui-eq__block-row", head, bundle(node)), body); if (o.open.has(id)) toggle(true); return box; } @@ -140,36 +132,6 @@ function markOf(line: EqLine): HTMLElement | null { return box; } -/** 값 숫자 자리 — 누르면 그 자리 고침 */ -function valueSpot(num: HTMLElement, leaf: () => EqNode, line: EqLine, o: EqLineOptions): void { - num.classList.add("is-value"); - num.title = "눌러서 값 고침"; - num.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (num.dataset.editing) return; - const unit = [...num.querySelectorAll(".ui-eq__unit")]; - unit.forEach((u) => u.remove()); - // 결과 값 칸은 글 + 단위 — 글만 고침 - editNumber(num, (text) => o.onValue?.(line, leaf(), text)); - num.addEventListener("blur", () => num.append(...unit), { once: true }); - }); -} - -/** 값 식 숫자 ↔ 기호 식 잎(같은 차례) — 수 · 입력 · 중간 · 표 찾기 · 요소만 고침(호표 단가 쪽 · 블록 · 금액 아님) */ -function values(box: HTMLElement, line: EqLine, o: EqLineOptions): void { - const leaves = leavesOf(line.이름식); - const skip = line.갈래 === "호표" ? leavesOf(line.이름식?.좌).length : 0; - box.querySelectorAll(".ui-eq__num, .ui-eq__str").forEach((num, at) => { - const leaf = leaves[at]; - const ok = - at >= skip && - leaf && - (leaf.꼴 === "수" || - (leaf.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(leaf.갈래 ?? ""))); - if (ok && num.classList.contains("ui-eq__num")) valueSpot(num, () => leaf, line, o); - }); -} - /** 줄 하나 */ export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement { const id = lineId(line); @@ -212,38 +174,24 @@ export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement { line.글 || "—", ...(resUnit ? [span("ui-eq__unit", resUnit)] : []), ); - if (o.onValue && line.갈래 === "중간" && head && line.글) valueSpot(result, () => head, line, o); - const lone = line.한마디 && line.이름식; - if (lone && line.이름식?.꼴 === "블록") blocks.push(line.이름식); // 펼침은 그대로 - if (line.이름식 && !lone) { + if (line.이름식) { const name = span("ui-eq__box ui-eq__name"); - name.append(renderEq(line.이름식, hand)); if (o.editing === id && o.editor) { name.classList.add("is-open"); - const expr = name.firstElementChild as HTMLElement; - // 호표 = 단가 × 수량 — 수량 쪽만 고침 - const target = line.갈래 === "호표" ? (expr.lastElementChild as HTMLElement) : expr; - const host = span("ui-eq__edit"); - target.replaceWith(host); - host.append(target); - o.editor(line, id, host); - } else if (o.onEdit) { - name.classList.add("is-edit"); - name.title = "눌러서 바로 고침 · 기호를 누르면 연결"; - name.addEventListener("click", (ev) => - o.onEdit?.(line, id, { x: ev.clientX, y: ev.clientY }), - ); + name.append(o.editor(line, id)); + } else { + name.append(renderEq(line.이름식, hand)); + if (o.onEdit) { + name.classList.add("is-edit"); + name.title = "눌러서 바로 고침"; + name.addEventListener("click", () => o.onEdit?.(line, id)); + } } body.append(span("ui-eq__op", "="), name); const same = plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식); - if (line.값식 && !same) { - const val = span("ui-eq__box ui-eq__val", renderEq(line.값식)); - if (o.onValue) values(val, line, o); - body.append(span("ui-eq__op", "="), val); - } - body.append(span("ui-eq__op", "="), result); - } else if (lone) { + if (line.값식 && !same) + body.append(span("ui-eq__op", "="), span("ui-eq__box ui-eq__val", renderEq(line.값식))); body.append(span("ui-eq__op", "="), result); } else { body.append( diff --git a/ui_template/ui_template_equation_text.ts b/ui_template/ui_template_equation_text.ts index d854814cd..1dfff6d6d 100644 --- a/ui_template/ui_template_equation_text.ts +++ b/ui_template/ui_template_equation_text.ts @@ -6,7 +6,10 @@ * 보이기 규칙 = `taken`(만약 = 탄 갈래만) · `HIDDEN`(반올림 = 자리 감싸개 숨김) — 셈 · 식은 그대로(그림만) * ========================================================================== */ -import type { EqNode } from "./ui_template_equation"; +import type { EqNode as Base } from "./ui_template_equation"; + +/** 서버 마디 + 키보드 글(`입력글` · 별칭 꼴 · 없으면 식) */ +type EqNode = Base & { 입력글?: string }; /** 그림에서 감싸개를 숨기는 함수(첫 인자만 보임) */ export const HIDDEN = new Set(["반올림"]);