- 공용 표 editOnDouble — 고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 입력 끝 - 공용 표 level.tree — ├ └ │ 안내선 + 폴더 · 파일 아이콘(공용 나무와 한 벌 TREE_ICON) - 설계내역서 — 폴더 줄 금액 = 자손 합 수식 풍선 · 고른 줄 = accent 상자 테두리 - test_235_1 새 시험 · test_228_1 들여쓰기 식 개수 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""235-1 B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자.
|
|
|
|
- 폴더 줄(자식 있는 줄 · 제목 · 묶음) = 이름만 고침 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용 · 연결 없음)
|
|
- 파일 줄(항목) = 이름 · 규격 · 단위 · 수량 입력 + 「연결 ▾」
|
|
- 트리 안내선(├ is-tee · └ is-end · │ is-bar) · 폴더 · 파일 아이콘(공용 나무 꼴) = 공용 표 level.tree
|
|
- 공용 표 editOnDouble = 고칠 칸도 글 · 한 번 누름 = 줄 고름 · 더블클릭 · edit() = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝
|
|
- 레벨 키 = 고른 줄에서 Enter · F2 = 입력 시작(편집 중 · 도구 단추 · 고른 줄 없음 = 안 먹음)
|
|
- 고른 줄 = 줄 전체 상자 테두리(테마 토큰 · 배경 대신)
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402
|
|
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
|
|
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
|
from test_184_3_b09_bill_menu import ROWS as FAKE_ROWS # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
|
|
|
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
|
TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8"))
|
|
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]
|
|
SUMS = ["sum", "labor", "material", "expense"]
|
|
FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6"
|
|
|
|
|
|
def _rows():
|
|
return compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
|
|
|
|
|
|
# --- 설계내역서 칸: 폴더 = 수식 · 파일 = 입력 ------------------------------------------------
|
|
|
|
SPEC_JS = """
|
|
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
|
const spec = buildBill(ctx);
|
|
const keys = ["name", "spec", "unit", "qty", "price_sum", "sum", "labor", "material", "expense", "connect"];
|
|
console.log(JSON.stringify({
|
|
double: spec.editOnDouble, tree: spec.level.tree,
|
|
rows: spec.rows.map((r, i) => ({ key: r.key, kind: r.kind, connect: r.connect,
|
|
edit: keys.filter((k) => spec.editable(i, k)),
|
|
tips: Object.fromEntries(keys.map((k) => [k, spec.tip(i, k) ?? null])) })),
|
|
}));
|
|
"""
|
|
|
|
|
|
def _spec(rows):
|
|
return json.loads(
|
|
_node(_ts(SRC) + SPEC_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)))
|
|
)
|
|
|
|
|
|
def test_폴더_줄_이름만_수량_단가_빈칸_금액_자손_합():
|
|
rows = _rows()
|
|
by = {r["key"]: r for r in rows}
|
|
folders = [r for r in rows if r["kind"] != "item"]
|
|
assert {"sec:1", "sec:1.1", "hand:p", "hand:q"} <= {r["key"] for r in folders}
|
|
for f in folders:
|
|
assert f.get("qty") is None and all(f.get(f"price_{p}") is None for p in [*SUMS[1:], "sum"])
|
|
kids = [r for r in rows if r["kind"] == "item" and _under(by, r, f["key"])]
|
|
for p in SUMS: # 금액 = 자손 항목 합(셈이 냄)
|
|
assert f[p] == sum(r[p] or 0 for r in kids), (f["key"], p)
|
|
out = _spec(rows)
|
|
assert out["double"] is True and out["tree"] is True
|
|
for r in out["rows"]:
|
|
if r["kind"] == "item":
|
|
continue
|
|
assert r["edit"] == ["name"], r["key"] # 이름만
|
|
assert r["connect"] == "" # 연결 없음
|
|
for p in SUMS:
|
|
assert r["tips"][p] == "수식 = 아래 항목 합(읽기 전용)"
|
|
assert r["tips"]["qty"] is None and r["tips"]["name"] is None
|
|
|
|
|
|
def _under(by, row, key):
|
|
at = by.get(row.get("parent") or "")
|
|
while at:
|
|
if at["key"] == key:
|
|
return True
|
|
at = by.get(at.get("parent") or "")
|
|
return False
|
|
|
|
|
|
def test_파일_줄_입력_연결():
|
|
out = _spec(_rows())
|
|
items = [r for r in out["rows"] if r["kind"] == "item" and r["key"] != "fee"]
|
|
assert items
|
|
for r in items:
|
|
assert r["edit"] == ["name", "spec", "unit", "qty"], r["key"]
|
|
assert all(r["tips"][p] is None for p in SUMS)
|
|
linked = [r for r in items if not r["key"].startswith("mat:")]
|
|
assert linked and all(r["connect"].endswith("▾") for r in linked)
|
|
|
|
|
|
# --- 트리 안내선 · 아이콘 ---------------------------------------------------------------------
|
|
|
|
|
|
def _guides_py(levels):
|
|
"""거울 — 아래에 같은 레벨 형제(더 얕은 줄 전까지)가 있으면 ├ · 없으면 └ · 윗대 형제가 있으면 │."""
|
|
|
|
def more(i, d):
|
|
for lv in levels[i + 1 :]:
|
|
if lv < d:
|
|
return False
|
|
if lv == d:
|
|
return True
|
|
return False
|
|
|
|
out = []
|
|
for i, lv in enumerate(levels):
|
|
g = [("is-bar", d - 2) for d in range(2, lv) if more(i, d)]
|
|
if lv >= 2:
|
|
g.append(("is-tee" if more(i, lv) else "is-end", lv - 2))
|
|
out.append(g)
|
|
return out
|
|
|
|
|
|
GUIDE_JS = """
|
|
const parse = (h) => [...h.matchAll(/ui-data-table__guide (is-\\w+)" style="--g:(\\d+)"/g)].map((m) => [m[1], Number(m[2])]);
|
|
const levels = __LEVELS__;
|
|
const unit = treeGuides(levels).map(parse);
|
|
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
|
const spec = buildBill(ctx);
|
|
const html = renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level,
|
|
editable: spec.editable, editOnDouble: true });
|
|
const trs = html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
|
const lvCell = (tr) => (tr.match(/<td class="[^"]*ui-data-table__lv[^"]*"[^>]*>(.*?)<\\/td>/) || [])[1] ?? "";
|
|
console.log(JSON.stringify({
|
|
unit,
|
|
bill: trs.map((tr) => parse(lvCell(tr))),
|
|
icons: trs.map((tr) => lvCell(tr).includes("__FOLDER__") ? "folder" : lvCell(tr).includes("__LEAF__") ? "leaf" : null),
|
|
tree: trs.map((tr) => /has-tree/.test(tr)),
|
|
levels: spec.rows.map((r) => r.level), kinds: spec.rows.map((r) => r.kind),
|
|
plain: renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: { ...spec.level, tree: false } })
|
|
.includes("ui-data-table__guide"),
|
|
}));
|
|
"""
|
|
|
|
|
|
def _guides(levels, rows):
|
|
body = (
|
|
GUIDE_JS.replace("__LEVELS__", json.dumps(levels))
|
|
.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
|
.replace("__FOLDER__", FOLDER_SVG)
|
|
.replace("__LEAF__", LEAF_SVG)
|
|
)
|
|
return json.loads(_node(_ts(SRC) + TABLE + body))
|
|
|
|
|
|
def test_트리_안내선_한_칸씩():
|
|
levels = [1, 2, 3, 3, 2, 3, 1, 2]
|
|
out = _guides(levels, _rows())
|
|
assert out["unit"] == [
|
|
[],
|
|
[["is-tee", 0]],
|
|
[["is-bar", 0], ["is-tee", 1]],
|
|
[["is-bar", 0], ["is-end", 1]],
|
|
[["is-end", 0]],
|
|
[["is-end", 1]],
|
|
[],
|
|
[["is-end", 0]],
|
|
]
|
|
assert out["unit"] == [[list(g) for g in row] for row in _guides_py(levels)]
|
|
|
|
|
|
def test_설계내역서_안내선_아이콘_공용_나무_꼴():
|
|
out = _guides([1], _rows())
|
|
assert out["bill"] == [[list(g) for g in row] for row in _guides_py(out["levels"])]
|
|
assert any(out["bill"]) # 실제로 선이 그려짐
|
|
assert out["icons"] == ["leaf" if k == "item" else "folder" for k in out["kinds"]]
|
|
assert all(out["tree"]) and out["plain"] is False # 나무 옵션 없으면 안내선 없음
|
|
table = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
|
tree = (ROOT / "ui_template" / "ui_template_tree.ts").read_text("utf-8")
|
|
assert FOLDER_SVG in table and LEAF_SVG in table # 아이콘 한 벌 — 공용 나무가 가져다 씀
|
|
assert 'import { TREE_ICON } from "./ui_template_data_table";' in tree
|
|
assert "TREE_ICON.folder : TREE_ICON.leaf" in tree and FOLDER_SVG not in tree
|
|
|
|
|
|
def test_안내선_CSS_테마_토큰():
|
|
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
|
block = css.split(".ui-data-table__guide::before {")[1].split("}")[0]
|
|
assert "border-left: 1px solid var(--color-border);" in block
|
|
assert ".ui-data-table__guide.is-end::before {\n bottom: 50%;" in css
|
|
assert ".ui-data-table__guide.is-tee::after,\n.ui-data-table__guide.is-end::after {" in css
|
|
assert "left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));" in css
|
|
assert ".ui-data-table__table td.has-tree:not(.is-frozen) {\n position: relative;" in css
|
|
|
|
|
|
# --- 공용 표 더블클릭 입력 ---------------------------------------------------------------------
|
|
|
|
DOM_JS = """
|
|
const doc = { activeElement: null };
|
|
globalThis.document = doc;
|
|
globalThis.CSS = { escape: (s) => s };
|
|
class FakeInput {
|
|
constructor(owner, i, k) { this.owner = owner; this.dataset = { i, k }; this.value = "v"; }
|
|
focus() { doc.activeElement = this; }
|
|
select() { this.selected = true; }
|
|
}
|
|
globalThis.HTMLInputElement = FakeInput;
|
|
class Root {
|
|
constructor() { this.on = {}; this.html = ""; this.clientHeight = 600; this.scrollTop = 0; this.dataset = {}; }
|
|
set innerHTML(h) { this.html = h; }
|
|
get innerHTML() { return this.html; }
|
|
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
|
|
fire(t, ev) { for (const f of this.on[t] ?? []) f(ev); }
|
|
contains(x) { return x === this || x?.owner === this; }
|
|
focus() { doc.activeElement = this; }
|
|
querySelector(sel) {
|
|
const m = sel.match(/^input\\[data-i="(\\d+)"\\]\\[data-k="(\\w+)"\\]$/);
|
|
if (!m || !this.html.includes(`data-i="${m[1]}" data-k="${m[2]}"`)) return null;
|
|
return new FakeInput(this, m[1], m[2]);
|
|
}
|
|
}
|
|
doc.createElement = () => new Root();
|
|
const target = (root, i, c) => {
|
|
const td = { dataset: {}, cellIndex: c };
|
|
const tr = { dataset: { i: String(i) }, owner: root };
|
|
return { closest: (s) => (s.startsWith("tr") ? tr : td) };
|
|
};
|
|
const inputs = (root) => [...root.html.matchAll(/<input[^>]*data-i="(\\d+)" data-k="(\\w+)"/g)].map((m) => `${m[1]}:${m[2]}`);
|
|
const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }];
|
|
const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }];
|
|
const log = [];
|
|
const t = createDataTable({ columns, rows, editOnDouble: true, editable: (i) => i === 1,
|
|
onCellClick: (i, k) => log.push(`click ${i}:${k}`), onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`) });
|
|
const r = t.root;
|
|
const out = { start: inputs(r) };
|
|
r.fire("click", { target: target(r, 1, 0) });
|
|
out.click = [inputs(r), [...log]];
|
|
r.fire("dblclick", { target: target(r, 0, 0) });
|
|
out.folderDbl = inputs(r);
|
|
r.fire("dblclick", { target: target(r, 1, 1) });
|
|
const input = doc.activeElement;
|
|
out.dbl = [inputs(r), input?.dataset ?? null, !!input?.selected];
|
|
let stopped = 0;
|
|
const key = (k, more = {}) => r.fire("keydown", { key: k, target: input, isComposing: false, ...more,
|
|
preventDefault() {}, stopPropagation() { stopped += 1; } });
|
|
key("Enter", { isComposing: true });
|
|
out.composing = [doc.activeElement === input, stopped];
|
|
key("Enter");
|
|
out.enter = [doc.activeElement === r, stopped];
|
|
r.fire("change", { target: input });
|
|
r.fire("focusout", { target: input });
|
|
setTimeout(() => {
|
|
out.after = [inputs(r), log.at(-1)];
|
|
out.editName = [t.edit(1), inputs(r)]; // 마지막 누른 열 = 명칭
|
|
out.editQty = [t.edit(1, "qty"), inputs(r)];
|
|
out.editFolder = t.edit(0);
|
|
const plain = createDataTable({ columns, rows, editable: (i) => i === 1 });
|
|
out.plain = [inputs(plain.root), plain.edit(1)];
|
|
console.log(JSON.stringify(out));
|
|
}, 5);
|
|
"""
|
|
|
|
|
|
def test_한_번_누름_고름_더블클릭_입력_Enter_끝():
|
|
out = json.loads(_node(TABLE + DOM_JS))
|
|
assert out["start"] == [] # 고칠 칸도 처음엔 글
|
|
assert out["click"] == [[], ["click 1:name"]] # 한 번 누름 = 줄 고름(글 상자 안 생김)
|
|
assert out["folderDbl"] == [] # 못 고치는 칸 더블클릭 = 그대로
|
|
assert out["dbl"] == [["1:qty"], {"i": "1", "k": "qty"}, True] # 그 칸만 · 초점 · 글 전체 고름
|
|
assert out["composing"] == [True, 0] # 한글 조합 중 Enter = 조합 끝
|
|
assert out["enter"] == [True, 1] # Enter = 표에 초점 · 문서로 안 감
|
|
assert out["after"] == [[], "edit 1:qty=v"] # 초점 떠남 = 입력 끝 · onEdit
|
|
assert out["editName"] == [True, ["1:name"]]
|
|
assert out["editQty"] == [True, ["1:qty"]]
|
|
assert out["editFolder"] is False
|
|
assert out["plain"] == [["1:name", "1:qty"], False] # 옵션 없는 표 = 예전대로 바로 글 상자
|
|
|
|
|
|
# --- 레벨 키: Enter · F2 = 입력 시작 --------------------------------------------------------
|
|
|
|
KEYS_JS = """
|
|
const edits = [];
|
|
const out = {};
|
|
draw();
|
|
out.none = [key("Enter"), edits.length];
|
|
pick("earth:5");
|
|
root.focus();
|
|
out.enter = [key("Enter"), [...edits]];
|
|
out.f2 = [key("F2"), [...edits]];
|
|
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
|
out.editing = [key("Enter"), edits.length];
|
|
doc.activeElement = bar.tools[0];
|
|
out.tool = [key("Enter"), edits.length];
|
|
root.focus();
|
|
out.composing = [key("Enter", { isComposing: true }), edits.length];
|
|
out.order = (user.bill?.rows ?? []).length;
|
|
console.log(JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def test_고른_줄_Enter_F2_입력_시작():
|
|
setup = BAR_JS.split("const out = {};")[0].replace(
|
|
"bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));"
|
|
)
|
|
body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False))
|
|
names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill",
|
|
"UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip
|
|
out = json.loads(_node(_ts(names) + body))
|
|
at = [r["key"] for r in FAKE_ROWS].index("earth:5")
|
|
assert out["none"] == [False, 0] # 고른 줄 없음
|
|
assert out["enter"] == [True, [at]]
|
|
assert out["f2"] == [True, [at, at]]
|
|
assert out["editing"] == [False, 2] # 글 상자 안 Enter = 공용 표 몫(입력 끝)
|
|
assert out["tool"] == [False, 2] # 도구 단추 Enter = 단추 몫
|
|
assert out["composing"] == [False, 2]
|
|
assert out["order"] == 0 # 줄 차례 · 레벨은 그대로
|
|
|
|
|
|
# --- 고른 줄 상자 · 배선 -----------------------------------------------------------------------
|
|
|
|
|
|
def test_고른_줄_상자_테두리_테마_토큰():
|
|
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
|
sel = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td'
|
|
top = "inset 0 2px 0 var(--color-accent),\n inset 0 -2px 0 var(--color-accent)"
|
|
assert f"{sel} {{\n box-shadow:\n {top};" in css
|
|
assert (
|
|
f"{sel}:first-child {{\n box-shadow:\n {top},\n inset 2px 0 0 var(--color-accent);"
|
|
in css
|
|
)
|
|
assert (
|
|
f"{sel}:last-child {{\n box-shadow:\n {top},\n inset -2px 0 0 var(--color-accent);"
|
|
in css
|
|
)
|
|
# 배경 대신 — 고른 줄 배경 = 보통 줄 배경(고친 칸 색은 그대로)
|
|
bill = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus'
|
|
assert (
|
|
f"{bill}:not(:hover) td:not(.is-edited) {{\n background-color: var(--color-canvas);" in css
|
|
)
|
|
assert (
|
|
f"{bill}.is-alt:not(:hover) td:not(.is-edited) {{\n background-color: var(--dt-zebra);"
|
|
in css
|
|
)
|
|
|
|
|
|
def test_Shell_배선():
|
|
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
|
assert "editOnDouble: spec.editOnDouble," in shell
|
|
assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell
|
|
for name in [
|
|
"ui_template/ui_template_data_table.ts",
|
|
"B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts",
|
|
]:
|
|
assert len((ROOT / name).read_text("utf-8").splitlines()) <= 700, name
|