diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 2fac2632e..fc8e13020 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -64,7 +64,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + (direct.blank ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` : ""), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index d8ea35242..f036c2206 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -5,12 +5,14 @@ * 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시 * {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]} * 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천) - * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct + * 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct) * 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김) * 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level · * 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비) - * 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기 + * 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제 * 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) + * 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 · + * 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞) * 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 · * 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`) * ========================================================================== */ @@ -33,7 +35,6 @@ export type BillTable = TabTable & { }; const NEW_NAME = "새 줄"; -const NEW_SECTION = "새 공종"; /** 고칠 칸 — 자동 줄은 덮개 */ const FIELDS = ["name", "spec", "unit", "qty", "ref"]; /** 깊이 한도 — 1 = 맨 위 공종 */ @@ -242,26 +243,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex if (run) parts[part].push([label, run]); }; - // 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞) - add(0, "위에 줄 추가", (w, e) => { - if (!e.parent) return false; - const at = find(w.bill.rows, key); - w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent)); - }); - add(0, "아래에 줄 추가", (w, e) => { - if (!e.parent || key === "fee") return false; - w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent)); - }); - add(0, "아래에 하위 줄 추가", (w) => { - if (!isGroup(w, key)) return false; - w.bill.rows.push(newItem(key)); - }); - add(0, "새 공종 추가(1단)", (w, e) => { + // 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠) + add(0, "항목 추가", (w, e) => { const list = w.bill.rows; - const from = find(list, key) < 0 ? String(e.parent) : key; - const top = chain(list, from).at(-1)!; - const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1); - list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION }); + const at = find(list, key); + if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key)); + else if (!e.parent) return false; + else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent)); }); if (linkable(row)) { const [picks, more] = linkFor(ctx, rows, i); @@ -292,10 +280,18 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); add(2, "내어쓰기 ←", (w, e) => { const up = e.parent ? entryOf(w, e.parent) : undefined; - if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴) - e.parent = up.parent; + if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨 pull(w.bill.rows, key); - w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e); + if (up.parent) e.parent = up.parent; + else { + // 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음) + delete e.parent; + e.cost = "direct"; + if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null }); + } + // 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤 + const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply"; + w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e); }); } if (!row.auto && row.kind === "item") @@ -315,7 +311,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex }); // 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움 if (!fixed(key) && !key.startsWith("mat:")) - add(3, "줄 지우기", (w, e) => { + add(3, "줄 삭제", (w, e) => { const list = w.bill.rows; if (!e.parent && list.some((c) => c.parent === key)) return false; for (const c of list) if (c.parent === key) c.parent = e.parent; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 0e384d428..9152d1120 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -100,7 +100,7 @@ async function importLib( ): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return; - showToast("서고에서 들여오는 중…", "info"); + showToast("가져오는 중…", "info"); try { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, { method: "POST", @@ -119,11 +119,11 @@ async function importLib( const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key); apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); showToast( - got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함", + got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함", "success", ); } catch (error) { - showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error"); + showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); } } @@ -157,12 +157,9 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L const picks: Leaf[] = [ ["일위대가 호표", () => pickHopyo(ctx, apply, "dg")], ["단가산출 호표", () => pickHopyo(ctx, apply, "sg")], - ["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)], - ["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)], - [ - "단가산출 로직(M01 · 새 산근)", - () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)), - ], + ["구조물 조합", () => pickCombo(ctx, apply)], + ["일위대가 사전조합", () => pickPrecombo(ctx, apply)], + ["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))], ]; return [picks, more]; } diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 45a41bbca..8941dc397 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -194,7 +194,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망) -def test_우클릭_메뉴_줄_추가_지우기_연결_TS(): +def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS(): # 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘 src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) rows = [ @@ -219,24 +219,24 @@ const run = (i, label) => {{ return user.bill; }}; const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }}; -out.above = run(2, "위에 줄 추가"); +out.below = run(2, "항목 추가"); out.reset = run(2, "자동값으로 되돌리기"); -out.left = run(3, "줄 지우기"); +out.left = run(3, "줄 삭제"); console.log(JSON.stringify(out)); """ out = json.loads(_node(src + body)) - add = ["위에 줄 추가", "아래에 줄 추가"] - assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"] - assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", - "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip + # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 + assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] + assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←", + "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑", - "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) - above = out["above"] - assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"] - new, earth, hand = above["rows"][2:] - assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2) + assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑", + "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) + below = out["below"] + assert [e["key"][:5] for e in below["rows"]] == ["sec:1", "sec:1", "earth", "hand:", "hand:"] + earth, new, hand = below["rows"][2:] + assert (new["name"], new["parent"], below["v"]) == ("새 줄", "sec:1.2", 2) assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100} assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"]) assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움 diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 1bac0f18f..1ae488159 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -189,15 +189,15 @@ const out = { g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"), b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"), }; -out.child = order(run("hand:g2", "아래에 하위 줄 추가")); -out.below = order(run("hand:g1", "아래에 줄 추가")); -out.belowB = order(run("hand:b", "아래에 줄 추가")); +out.child = order(run("hand:g2", "항목 추가")); +out.below = order(run("hand:g1", "항목 추가")); +out.belowB = order(run("hand:b", "항목 추가")); out.indentBill = run("hand:x", "들여쓰기 →"); out.indent = order(out.indentBill); out.outdent = order(run("hand:g2", "내어쓰기 ←")); out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key)); out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x"); -out.removed = order(run("hand:g1", "줄 지우기")); +out.removed = order(run("hand:g1", "줄 삭제")); console.log(JSON.stringify(out)); """ @@ -221,15 +221,16 @@ def test_우클릭_레벨_메뉴_TS(): out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body)) level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 - assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"] + assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나 assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"] assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음 assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목 assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지 assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1 - assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움 - assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤 - assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모 + assert pick("auto") == ["내어쓰기 ←"] and "줄 삭제" in out["auto"] # 자동 줄도 지움 + # 246-1 항목 추가 — 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + assert out["child"] == _put(BASE, 4, ["NEW", "hand:g2"]) + assert out["below"] == _put(BASE, 2, ["NEW", "hand:g1"]) assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"]) assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식 assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤 @@ -320,10 +321,10 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): picks = [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] # 204-2 + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 204-2 · 246-1 이름 셋 # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 78f99583a..e7e72e92b 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -1,4 +1,4 @@ -"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 · +"""184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) · 자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선. - 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄 @@ -119,26 +119,26 @@ ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"] def test_메뉴_모든_줄(): out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2", "mat:M1", "sec:G"])["labels"] # fmt: skip - add = ["위에 줄 추가", "아래에 줄 추가"] - # 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움) - assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"] - # 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음) - assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", - "아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip + add = ["항목 추가"] # 246-1 더함 하나 + # 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음) + assert out["sec:1"] == [*add, "-", "아래로 ↓"] + # 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음) + assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-", + "아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip # 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음) - assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓", + assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기", - "줄 지우기"] # fmt: skip - assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑", - "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-", - "줄 지우기"] # fmt: skip - assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←", - "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip - # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 - assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정 - assert out["mat:M2"] == [*add, "새 공종 추가(1단)"] - assert out["mat:M1"] == [*add, "새 공종 추가(1단)"] - assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"] + "줄 삭제"] # fmt: skip + assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←", + "항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip + assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←", + "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip + # 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 · + # 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도) + assert out["fee"] == add # 조달수수료 = 관급자재대 끝 고정 + assert out["mat:M2"] == add + assert out["mat:M1"] == add + assert out["sec:G"] == add def test_칸_고침_덮개_옛_꼴_굳힘(): @@ -166,38 +166,34 @@ V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k if k != "hand:b"], "gone": []}} # fmt: skip -def test_지움_옮김_새_공종_자동값으로(): +def test_삭제_옮김_새_공종_자동값으로(): user = {"bill": {**V2["bill"], "rows": [ {**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip - runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"), - ("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"), - ("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"), - ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"), - ("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), - ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"), - ("fee", "새 공종 추가(1단)")] # fmt: skip + runs = [("earth:4", "줄 삭제"), ("hand:g", "줄 삭제"), ("earth:5", "위로 ↑"), + ("earth:4", "아래로 ↓"), ("sec:1.2", "내어쓰기 ←"), + ("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"), + ("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"), + ("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"), + ("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip out = _menu(user, runs=runs)["runs"] keys = lambda b: [k for k, _ in _order(b)] # noqa: E731 # 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘 base = keys(out["earth:5|위로 ↑"]) assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1 # 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로 - b = out["earth:4|줄 지우기"] + b = out["earth:4|줄 삭제"] assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b) - b = out["hand:g|줄 지우기"] + b = out["hand:g|줄 삭제"] assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b) # 위로 · 아래로 = 같은 부모 형제와 자리 바꿈 for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"): got = keys(out[k]) assert got[got.index("earth:5") + 1] == "earth:4" - # 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞 - b = out["earth:4|새 공종 추가(1단)"] - new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0] - assert (new["kind"], new["cost"], new["name"], "parent" in new) == ( - "group", "direct", "새 공종", False) # fmt: skip - assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4" - b = out["mat:M2|새 공종 추가(1단)"] - assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:") + # 246-1 새 공종 = 2단을 ← 로 맨 위(cost direct · 옛 부모 바로 뒤) · 관급 자재 = 못 냄 + b = out["sec:1.2|내어쓰기 ←"] + assert [k for k, p in _order(b) if p is None] == ["sec:1", "sec:1.2", "sec:4", "sec:G"] + assert {e["key"]: e for e in b["rows"]}["sec:1.2"]["cost"] == "direct" + assert out["mat:M2|내어쓰기 ←"] is None # 자동값으로 = 고친 칸 지움(차례 · 부모 그대로) b = out["earth:5|자동값으로 되돌리기"] assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"} @@ -207,23 +203,22 @@ def test_지움_옮김_새_공종_자동값으로(): assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2") got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤) assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0 - # 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목) - got = out["sec:1.2|아래에 하위 줄 추가"]["rows"] + # 246-1 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · 새 줄 = kind 없음(항목) + got = out["sec:1.2|항목 추가"]["rows"] kids = [e for e in got if e.get("parent") == "sec:1.2"] - assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1] - got = out["earth:4|위에 줄 추가"]["rows"] + assert kids[0]["name"] == "새 줄" and "kind" not in kids[0] + got = out["earth:4|항목 추가"]["rows"] at = [e["key"] for e in got].index("earth:4") - assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2") + assert (got[at + 1]["name"], got[at + 1]["parent"]) == ("새 줄", "sec:1.2") a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"] assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None) got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]} assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2" # 자동 줄 연결 풀기 = ref 덮개 null assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None - # 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞 - got = out["fee|위에 줄 추가"]["rows"] + # 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) + got = out["fee|항목 추가"]["rows"] assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2") - assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2 def test_관급_옮김_막힘_깊이_5(): @@ -237,15 +232,15 @@ def test_관급_옮김_막힘_깊이_5(): out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"] assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음 assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대) - # 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음 + # 깊이 5 — 레벨 5 묶음에 항목 추가(첫 하위) · 레벨 5 줄 들여쓰기 = 막음 deep = [ _row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"), _row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"), _row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5), ] # fmt: skip out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"] - assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] - assert "아래에 하위 줄 추가" in out["hand:g2"] + assert "항목 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"] + assert "항목 추가" in out["hand:g2"] TABLE_JS = """ diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index c89d23564..4da59ffb2 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -202,9 +202,9 @@ const applied = []; const apply = (ref, from) => applied.push([ref, from && from.name]); const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply); const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) }; -picks.find((x) => x[0].startsWith("서고 일위대가"))[1](); +picks.find((x) => x[0] === "일위대가 사전조합")[1](); await new Promise((r) => setTimeout(r, 0)); -picks.find((x) => x[0].startsWith("서고 구조물"))[1](); +picks.find((x) => x[0] === "구조물 조합")[1](); await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); Object.assign(out, { sent, applied, payload: ctx.payload, toasts }); @@ -226,10 +226,10 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS(): assert out["picks"] == [ "일위대가 호표", "단가산출 호표", - "서고 구조물조합(마스터 · 회사 · 개인)", - "서고 일위대가 사전조합(마스터 · 회사 · 개인)", - "단가산출 로직(M01 · 새 산근)", - ] + "구조물 조합", + "일위대가 사전조합", + "단가산출", + ] # 246-1 이름 셋(서고 · 층 · M01 글 없음) assert out["more"] == ["연결 풀기"] pre, (url, body) = out["sent"][0], out["sent"][1] assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋 diff --git a/resources/tester/test_241_1_input_cell_border.py b/resources/tester/test_241_1_input_cell_border.py index 6ab516573..4d1b2a9d6 100644 --- a/resources/tester/test_241_1_input_cell_border.py +++ b/resources/tester/test_241_1_input_cell_border.py @@ -1,4 +1,4 @@ -"""PLAN 241-1 · 123 — B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리.""" +"""PLAN 241-1 · 123 → 246-1 · 128 — B08 · B09 표 입력 칸 = 테두리 되돌림(241 앞 채움 색).""" import re from pathlib import Path @@ -13,17 +13,13 @@ def _rule(css: str, selector: str) -> str: return m.group(1) -def test_입력_칸_채움_색_없고_안쪽_테두리(): +def test_입력_칸_채움_색_테두리_없음(): body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input") - assert "background: transparent;" in body - assert "background: color-mix" not in body # 옛 accent 5% 채움 없음 - # 241-2 두 테마 — 진한 accent 60% + 캔버스(밝음 · 어두움 모두 캔버스 대비 3:1 넘음 · 초점 2px 와 갈림) - assert ( - "box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" - in body - ) + # 246-1 — 241 앞 모양(accent 5% 채움) · 안쪽 테두리 없음 + assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body + assert "box-shadow" not in body assert "#" not in body # 하드코딩 색 없음 — 테마 변수만 - assert "border: 0;" in body # 테두리는 inset — 칸 크기 · 줄 높이 불변 + assert "border: 0;" in body assert "height: var(--dt-row-h);" in body diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index 61be0ddae..5e5e52544 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -1,7 +1,7 @@ -"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 늘 보임. +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움. - 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 -- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(입력 칸 테두리) · 못 고치는 칸 · 옛 방식 표 = 없음 +- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양) - B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble - 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 """ @@ -74,9 +74,9 @@ setTimeout(() => { """ -def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): +def test_공용_표_더블클릭_Enter_F2(): out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) - assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시 + assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음 assert out["start"] == [] # 처음 = 글 상자 없음 assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 @@ -90,18 +90,10 @@ def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) -def test_고칠_칸_테두리_CSS_테마_변수만(): +def test_고칠_칸_테두리_CSS_없음(): css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") - m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) - assert m - body = m.group(1) - # 글 상자와 같은 색(accent 60% · canvas) — 줄 고름 box-shadow 와 안 겹치게 outline 안쪽 - assert ( - "outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" - in body - ) - assert "outline-offset: -1px;" in body - assert "#" not in body + ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림 def _sources(*folders: str) -> list[Path]: diff --git a/resources/tester/test_246_1_b09_bill_menu_simple.py b/resources/tester/test_246_1_b09_bill_menu_simple.py new file mode 100644 index 000000000..7a76c6545 --- /dev/null +++ b/resources/tester/test_246_1_b09_bill_menu_simple.py @@ -0,0 +1,82 @@ +"""246-1 · 128 — B09 설계내역서 우클릭 메뉴 줄임 · 입력 칸 테두리 되돌림. + +- 입력 칸 = 241 앞 모양(글 상자 accent 5% 채움 · td 표시 없음) +- 더함 = 「항목 추가」 하나 — 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 +- 맨 위 1단(새 공종) = ← 내어쓰기 — 직접비 · 손 줄은 묶음 · 관급자재대 앞 · 자재 줄은 못 냄 +- 연결 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」 · 지움 = 「줄 삭제」 +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import DG, ROWS, _menu, _order, _row # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"] + + +def test_입력_칸_테두리_없음(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + body = css.split(".ui-data-table__input {")[1].split("}")[0] + assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body + assert "box-shadow" not in body and "is-can-edit" not in css + + +def test_더함_하나_줄_삭제_연결_이름_셋(): + keys = ["sec:1", "earth:4", "hand:g", "hand:a", "fee", "sec:G"] + out = _menu({}, labels=keys)["labels"] + for key in keys: + assert [x for x in out[key] if "추가" in x] == ["항목 추가"], key + assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key] + assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"] + link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8") + picks = link.split("const picks: Leaf[] = [")[1].split("];")[0] + for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]: + assert f'["{name}",' in picks + for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]: + assert word not in picks + toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]] + assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말 + bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8") + assert "항목 추가 · 줄 삭제" in bill # 표 아래 안내 글 + + +#: 관급자재대 안 손 묶음(hand:s · 자재 없음) · 손 항목(hand:t) +SUPPLY = [*ROWS[:-1], _row("hand:s", "sec:G", 2, "group", cost="supply"), + _row("hand:t", "hand:s", 3, cost="supply", qty=1, ref=DG), ROWS[-1]] # fmt: skip + + +def test_자리_규칙(): + runs = [("hand:g", "항목 추가"), ("hand:a", "항목 추가"), ("hand:b", "항목 추가"), + ("hand:a", "내어쓰기 ←"), ("hand:g", "내어쓰기 ←"), ("hand:s", "내어쓰기 ←"), + ("hand:t", "내어쓰기 ←"), ("mat:M1", "내어쓰기 ←")] # fmt: skip + out = _menu({}, runs=runs, rows=SUPPLY)["runs"] + new = lambda b: [(e.get("parent"), e.get("name", e["key"])) for e in b["rows"]] # noqa: E731 + # 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모 + got = new(out["hand:g|항목 추가"]) + assert got[got.index(("sec:1.2", "hand:g")) + 1] == ("hand:g", "새 줄") + got = [e["key"] for e in out["hand:a|항목 추가"]["rows"]] + row = out["hand:a|항목 추가"]["rows"][got.index("hand:a") + 1] + assert (row["name"], row["parent"]) == ("새 줄", "hand:g") + got = out["hand:b|항목 추가"]["rows"] + row = got[[e["key"] for e in got].index("hand:b") + 1] + assert (row["name"], row["parent"], "kind" in row) == ("새 줄", "sec:1.2", False) + # ← = 한 단씩 — 레벨 4 → 3(sec:1.2 아래) · 레벨 3 → 2(sec:1 아래) + assert ("hand:a", "sec:1.2") in _order(out["hand:a|내어쓰기 ←"]) + assert ("hand:g", "sec:1") in _order(out["hand:g|내어쓰기 ←"]) + # 관급 안 손 묶음 ← = 새 공종(직접비) · 관급자재대 앞 + b = out["hand:s|내어쓰기 ←"] + tops = [k for k, p in _order(b) if p is None] + assert tops == ["sec:1", "sec:4", "hand:s", "sec:G"] + s = {e["key"]: e for e in b["rows"]}["hand:s"] + assert (s["kind"], s["cost"]) == ("group", "direct") + # 손 항목 ← 관급 묶음 밖(sec:G 아래) · 자재 줄 = 1단 안 됨 + assert ("hand:t", "sec:G") in _order(out["hand:t|내어쓰기 ←"]) + assert out["mat:M1|내어쓰기 ←"] is None + # 1단 손 항목 → 묶음(수량 · 연결 지움) — 2단 손 항목을 두 번 냄 + one = _menu({"bill": out["hand:t|내어쓰기 ←"]}, runs=[("hand:t", "내어쓰기 ←")], + rows=SUPPLY)["runs"]["hand:t|내어쓰기 ←"] # fmt: skip + t = {e["key"]: e for e in one["rows"]}["hand:t"] + assert ("parent" in t, t["kind"], t["qty"], t["ref"], t["cost"]) == ( + False, "group", None, None, "direct") # fmt: skip diff --git a/resources/tester/test_246_2_context_submenu_side.py b/resources/tester/test_246_2_context_submenu_side.py new file mode 100644 index 000000000..3c09cc09a --- /dev/null +++ b/resources/tester/test_246_2_context_submenu_side.py @@ -0,0 +1,168 @@ +"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판. + +- 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점) +- 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘 +- 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) + +DOM_JS = """ +const docOn = {}; +class El { + constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {}, + className: "", hidden: false, textContent: "", attrs: {} }); } + get classList() { + const el = this; + const list = () => el.className.split(" ").filter(Boolean); + return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); }, + remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")), + contains: (c) => list().includes(c) }; + } + get ownerDocument() { return { defaultView: globalThis.view }; } + get firstElementChild() { return this.kids[0] ?? null; } + get lastElementChild() { return this.kids.at(-1) ?? null; } + setAttribute(k, v) { this.attrs[k] = v; } + append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } } + replaceChildren() { this.kids = []; } + remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); } + contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {}, + stopPropagation() {}, ...ev }); } + focus() { doc.activeElement = this; } + all() { return this.kids.flatMap((k) => [k, ...k.all()]); } + match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c)) + : this.tag === sel; } + querySelectorAll(sel) { + let found = [this]; + for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part))); + return found; + } + querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; } + getBoundingClientRect() { + if (this.style.left !== undefined) { + const side = this.classList.contains("ui-ctx-menu__side"); + const left = parseFloat(this.style.left), top = parseFloat(this.style.top); + const width = side ? 150 : 160; + const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length; + return { left, top, width, height, right: left + width, bottom: top + height }; + } + const box = this.parent.getBoundingClientRect(); + const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this); + return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 }; + } +} +const doc = { activeElement: null, createElement: (t) => new El(t), + addEventListener: (t, f) => (docOn[t] ??= []).push(f) }; +globalThis.document = doc; +globalThis.Node = El; +globalThis.view = { innerWidth: 1000, innerHeight: 800 }; +globalThis.window = globalThis.view; +""" + +SCENE_JS = """ +const log = []; +const menu = createMapContextMenu("b09-estimation"); +const items = [["A", () => log.push("A")], + { label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-", + ["B", () => log.push("B")]]; +const el = menu.element; +const btns = () => el.kids.filter((k) => k.tag === "button"); +const side = () => el.querySelector(".ui-ctx-menu__side"); +const sideText = () => side()?.kids.map((k) => k.textContent) ?? null; +const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position]; +const out = {}; +menu.open(100, 100, items); +const head = btns()[1]; +out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]]; +head.fire("pointerenter"); +out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className]; +out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top; +out.contains = menu.contains(side().kids[0]); +btns()[0].fire("pointerenter"); +out.leave = [sideText(), head.classList.contains("is-open")]; +head.fire("click"); +out.click = [sideText(), doc.activeElement?.textContent]; +side().fire("keydown", { key: "ArrowLeft" }); +out.left = [sideText(), doc.activeElement === head]; +head.fire("keydown", { key: "ArrowRight" }); +out.right = [sideText(), doc.activeElement?.textContent]; +side().kids[1].fire("click"); +out.pick = [el.hidden, el.kids.length, [...log]]; +// 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽 +view.innerWidth = 400; +menu.open(300, 100, items); +btns()[1].fire("pointerenter"); +out.edge = [parseFloat(el.style.left), at()[0]]; +// 아래 끝 — 옆 판 올림 +view.innerWidth = 1000; view.innerHeight = 100; +menu.open(100, 10, items); +btns()[1].fire("pointerenter"); +out.bottom = at()[1]; +view.innerHeight = 800; +// Esc · 바깥 누름 = 모두 닫힘 +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} }); +out.esc = [el.hidden, side()]; +menu.open(100, 100, items); +btns()[1].fire("pointerenter"); +const inside = side().kids[0]; +for (const f of docOn.pointerdown) f({ target: inside }); +out.insideDown = el.hidden; +for (const f of docOn.pointerdown) f({ target: new El("div") }); +out.outsideDown = el.hidden; +console.log(JSON.stringify(out)); +""" + + +def _node(source: str) -> str: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=source, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return result.stdout + + +def _src() -> str: + return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8") + + +def test_옆_판_열기_닫기_자리(): + out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS)) + # 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음) + assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"] + # 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed + names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side" + assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names] + assert out["firstRow"] is True and out["contains"] is True + assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘 + assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점 + assert out["left"] == [None, True] # ← = 닫고 머리 줄로 + assert out["right"] == [["x", "y"], "x"] # → = 열기 + assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작 + # 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150) + assert out["edge"] == [236, 86] + # 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4 + assert out["bottom"] == 100 - 4 - 68 + assert out["esc"] == [True, None] + assert out["insideDown"] is False and out["outsideDown"] is True + + +def test_옛_아코디언_없음_공용_꼴(): + src = _src() + assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src + css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8") + assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css + # B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로) + edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8") + assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 7b11ab018..c726c6c66 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -47,22 +47,13 @@ cursor: not-allowed; } -/* 하위 메뉴(아코디언) — 머리글 굵게 · 종류는 들여쓰기 */ +/* 하위 메뉴(246-2) — 머리 줄 굵게 · 연 동안 머리 줄 칠함 · 옆 판 = 같은 메뉴 꼴(자리 = fixed · 스크립트) */ .ui-ctx-menu__item.is-group { font-weight: var(--font-weight-medium); } -.ui-ctx-menu__sub { - display: flex; - flex-direction: column; -} - -.ui-ctx-menu__sub[hidden] { - display: none; -} - -.ui-ctx-menu__item.is-sub { - padding-left: var(--spacing-24); +.ui-ctx-menu__item.is-open { + background: var(--color-surface-sunken, var(--color-mist-violet)); } /* 구분선(`"-"` 항목) */ diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 4edc8545b..514413a6c 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -13,8 +13,8 @@ import "./ui_template_context_menu.css"; -/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). +/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면 + * 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2). * 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */ export type MapContextMenuItem = | [label: string, action: () => void] @@ -104,7 +104,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { const entry = document.createElement("button"); entry.type = "button"; entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`; - if (sub) entry.classList.add("is-sub"); + if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘 entry.textContent = label; entry.addEventListener("click", () => { close(); @@ -113,7 +113,72 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { return entry; } - /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 · 하위 메뉴를 펼칠 때 */ + /** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */ + function closeSide(): void { + element.querySelector(".ui-ctx-menu__side")?.remove(); + element + .querySelectorAll(".ui-ctx-menu__item.is-open") + .forEach((head) => head.classList.remove("is-open")); + } + + /** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) · + * 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */ + function placeSide(side: HTMLElement, head: HTMLElement): void { + const view = element.ownerDocument.defaultView ?? window; + const menu = element.getBoundingClientRect(); + let x = menu.right; + let y = head.getBoundingClientRect().top; + side.style.left = `${x}px`; + side.style.top = `${y}px`; + const box = side.getBoundingClientRect(); + const [ox, oy] = [box.left - x, box.top - y]; + y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이 + if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width); + if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height); + side.style.left = `${x - ox}px`; + side.style.top = `${y - oy}px`; + } + + /** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */ + function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement { + const head = document.createElement("button"); + head.type = "button"; + head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; + head.setAttribute("aria-haspopup", "menu"); + head.textContent = `${label} ▸`; + const show = (focus: boolean): void => { + if (!head.classList.contains("is-open")) { + closeSide(); + const side = document.createElement("div"); + side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`; + side.style.position = "fixed"; + side.setAttribute("role", "menu"); + children.forEach(([text, action]) => side.append(buildLeaf(text, action, true))); + side.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + closeSide(); + head.focus(); + }); + element.append(side); + head.classList.add("is-open"); + placeSide(side, head); + } + if (focus) element.querySelector(".ui-ctx-menu__side button")?.focus(); + }; + head.addEventListener("pointerenter", () => show(false)); + head.addEventListener("click", () => show(true)); + head.addEventListener("keydown", (event) => { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + show(true); + }); + return head; + } + + /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */ function fit(): void { const x = parseFloat(element.style.left); const y = parseFloat(element.style.top); @@ -144,29 +209,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.append(buildLeaf(item[0], item[1])); return; } - // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. - // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). - const head = document.createElement("button"); - head.type = "button"; - head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; - head.textContent = `${item.label} ▸`; - const body = document.createElement("div"); - body.className = `${blockClass}__context-menu-sub ui-ctx-menu__sub`; - body.hidden = true; - item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); - head.addEventListener("click", () => { - const willOpen = body.hidden; - element - .querySelectorAll(`.${blockClass}__context-menu-sub`) - .forEach((other) => (other.hidden = true)); - element - .querySelectorAll(`.${blockClass}__context-menu-item.is-group`) - .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); - body.hidden = !willOpen; - head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; - fit(); // 펼친 하위가 화면 아래로 넘치면 메뉴를 올림 - }); - element.append(head, body); + // 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나) + element.append(buildGroup(item.label, item.children)); }); if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep")) element.lastElementChild.remove(); diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index fb9c61cc1..f17dc400a 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -158,7 +158,7 @@ box-shadow: inset 0 -2px 0 var(--color-accent); } -/* 고칠 칸(PLAN 121 · 241-1) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 · 입력 칸 표시 = 채움 색 없이 칸 안쪽 테두리(칸 크기 불변) */ +/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */ .ui-data-table__table td.is-edit { padding: 0; } @@ -168,20 +168,13 @@ height: var(--dt-row-h); padding: 0 var(--spacing-8); border: 0; - background: transparent; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); color: inherit; font: inherit; font-variant-numeric: tabular-nums; text-align: right; } -/* 고칠 수 있는 칸(PLAN 243-1 · editOnDouble) — 글일 때도 같은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ -.ui-data-table__table td.is-can-edit { - outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); - outline-offset: -1px; -} - .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index edc1d0bbb..afc3bc564 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,8 +17,7 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 - * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 243-1) · - * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(keyEdit false = 표 밖이 맡음) + * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -184,7 +183,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans, can } = more; + const { lv, spans } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -210,7 +209,6 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), - can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -243,8 +241,6 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; - /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시) */ - can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -360,7 +356,6 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), - ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); }