Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-12 19:15:26 +09:00
11 changed files with 551 additions and 53 deletions
@@ -1,9 +1,9 @@
/* =============================================================================
* B05_Profile_UI_Structures_Summary_Row.ts
* 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [고르기] · [해제] (PLAN 21-4).
* 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [해제] (PLAN 21-4) · 이름 칸을 누르면 고르기(280-1).
*
* B05 · B06 · 관 계열 하위 폼이 같은 줄을 쓴다(옵션 · 서브폼 아래 한 줄). 줄은 값만 들고 있고
* 저장은 패널이 한다 — [고르기]가 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다.
* 저장은 패널이 한다 — 이름 칸(누름 · Enter · Space)이 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다.
* 키(SS…)는 앞에 내세우지 않고 툴팁에만 둔다(PLAN 21-0).
* ========================================================================== */
@@ -18,29 +18,26 @@ export interface SummaryRow {
get: () => SummaryItem | null;
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
set: (item: SummaryItem | null) => void;
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
/** 고르기(이름 칸) · [해제] 배선 — 패널이 한 번 부른다. */
bind: (handlers: { pick: () => void; clear: () => void }) => void;
}
const PICK_TIP = "눌러서 고르기";
export function buildSummaryRow(): SummaryRow {
// 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제]
// 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][해제]
const root = document.createElement("div");
root.className = "b05-route__field b05-structure__summary";
root.dataset.b05Summary = "row";
const label = document.createElement("span");
label.className = "b05-structure__summary-label";
label.textContent = "구조물 조합";
// 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음
// 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 · 누르면 고르기(280-1)
const name = document.createElement("input");
name.type = "text";
name.readOnly = true;
name.tabIndex = -1;
name.className = "b05-structure__summary-name";
const pick = document.createElement("button");
pick.type = "button";
pick.className = "b05-route__irregular-btn";
pick.textContent = "선택";
pick.dataset.b05Summary = "pick";
name.dataset.b05Summary = "pick";
const clear = document.createElement("button");
clear.type = "button";
clear.className = "b05-route__irregular-btn";
@@ -48,14 +45,14 @@ export function buildSummaryRow(): SummaryRow {
clear.dataset.b05Summary = "clear";
const line = document.createElement("div");
line.className = "b05-route__irregular-actions";
line.append(name, pick, clear);
line.append(name, clear);
root.append(label, line);
let current: SummaryItem | null = null;
const set = (item: SummaryItem | null): void => {
current = item;
name.value = summaryLabel(item);
name.title = summaryTooltip(item);
name.title = `${summaryTooltip(item)} · ${PICK_TIP}`;
name.classList.toggle("is-assigned", item !== null);
clear.disabled = item === null;
};
@@ -66,7 +63,12 @@ export function buildSummaryRow(): SummaryRow {
get: () => current,
set,
bind(handlers) {
pick.addEventListener("click", () => handlers.pick());
name.addEventListener("click", () => handlers.pick());
name.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
handlers.pick();
});
clear.addEventListener("click", () => handlers.clear());
},
};
@@ -436,7 +436,8 @@
}
/* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은
* [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */
* [이름 칸][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색.
* 이름 칸 = 고르기 단추 노릇(280-1) — 손 커서 · 키보드 초점 테두리. */
.b05-structure__summary[hidden] {
display: none;
}
@@ -445,6 +446,12 @@
flex: 1 1 auto;
align-self: center;
text-overflow: ellipsis;
cursor: pointer;
}
.b05-structure__summary-name:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
/* 연결됨 — 연한 초록 배경 + 초록 테두리 · 글은 본문색 그대로(밝음 · 어두움 같은 토큰). */
@@ -24,7 +24,9 @@ const node = (props = {}) => ({
const STUBS = {
"./ui_template_elements": { el: (_tag, props) => node(props) },
"./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") },
"./ui_template_locale": {
t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중"),
},
"../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": {
readTemplate: async (...args) => {
log.push(["readTemplate", ...args]);
@@ -77,6 +79,10 @@ function loadTs(file) {
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
const row = (key, 열, layer, owner) => ({ key, 열, layer, owner });
const names = () => log.map((entry) => entry[0]);
// 280-2 — 표는 층(+ 주인) · 열을 받음
const at = (r) => (r ? { layer: r.layer, owner: r.owner } : null);
const show = (table, r) => table.show(at(r), r ? r.열 : "");
const picked = [];
const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]);
(async () => {
@@ -87,13 +93,13 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent
const sameLayer = row("SS2", "rv2", "personal", "1/3");
const master = row("SS9", "rv9", "system", null);
const table = createComboTable();
const table = createComboTable((colId) => picked.push(colId));
out.startHidden = table.root.hidden;
table.show(null);
show(table, null);
out.nullHidden = table.root.hidden;
out.nullCalls = names();
table.show(mine);
show(table, mine);
out.shownHidden = table.root.hidden;
await flush();
out.firstReads = log.filter((e) => e[0] !== "createSheet");
@@ -111,12 +117,12 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent
};
log.length = 0;
table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만
show(table, sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만
await flush();
out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed };
log.length = 0;
table.show(master); // 다른 층 — 그 층 표로 바뀜
show(table, master); // 다른 층 — 그 층 표로 바뀜
await flush();
out.otherLayer = {
reads: log.filter((e) => e[0] === "readTemplate"),
@@ -127,21 +133,21 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent
};
log.length = 0;
table.show(null);
show(table, null);
out.hideAgain = table.root.hidden;
// 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐
log.length = 0;
table.show(mine);
table.show(master);
show(table, mine);
show(table, master);
await flush();
out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag };
// 번호 읽기 실패 — 표는 그려짐
log.length = 0;
state.numbersFail = true;
const lone = createComboTable();
lone.show(mine);
const lone = createComboTable((colId) => picked.push(colId));
show(lone, mine);
await flush();
out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers };
state.numbersFail = false;
@@ -149,8 +155,8 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent
// 표 읽기 실패 — 까닭 글 · 표 없음
log.length = 0;
state.tableFail = "그 층은 아직 만든 것이 없음";
const broken = createComboTable();
broken.show(mine);
const broken = createComboTable((colId) => picked.push(colId));
show(broken, mine);
await flush();
out.tableFail = { creates: creates().length, text: broken.root.children[0].text };
@@ -160,6 +166,24 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent
ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")),
mine: data.tableKey(mine),
};
// 280-2 — 표에서 고른 열은 부른 쪽에 넘김 · 열 없는 고름(colId null)은 안 넘김
state.tableFail = null;
const fresh = createComboTable((colId) => picked.push(colId));
log.length = 0;
show(fresh, mine);
await flush();
const sheetOpts = creates()[0].opts;
sheetOpts.onSelect({ colId: "rv2" });
sheetOpts.onSelect({ colId: null });
out.onColumn = picked.slice(-1);
// 층만 바꿈(고른 열 없음) — 그 층 표를 그리고 열은 안 고름
log.length = 0;
fresh.show({ layer: "system", owner: null }, "");
await flush();
out.layerOnly = {
reads: log.filter((e) => e[0] === "readTemplate"),
selected: creates()[0].selected,
};
console.log(JSON.stringify(out));
})().catch((error) => {
console.error(error);
@@ -0,0 +1,264 @@
/* 구조물 조합 선택 모달(PLAN 280-2) 시험 헬퍼 — `ui_template_item_picker.ts` 를 트랜스파일해
* 화면 부품(요소 · 모달 · 나무 · 카드 · 위 표)을 대역으로 갈아 끼우고 돌림 · 자료 쪽(`_data`)은 진짜.
* 위 표 고름 → 나무(펼쳐 고름) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층 — 결과는 표준출력에 JSON 한 벌.
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const UI = path.join(ROOT, "ui_template");
// ── 화면 그릇 흉내 — 클래스 · 자식 · 이벤트 · 숨김만 ─────────────────────
class FakeNode {
constructor(props = {}) {
this._classes = new Set(
String(props.className ?? "")
.split(/\s+/)
.filter(Boolean),
);
this.textContent = props.text ?? "";
this.children = [...(props.children ?? [])];
this.hidden = !!(props.attrs && "hidden" in props.attrs);
this.disabled = false;
this.dataset = {};
this.attrs = {};
this.listeners = {};
this.value = "";
this.style = { setProperty() {} };
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
}
append(...kids) {
this.children.push(...kids);
}
replaceChildren(...kids) {
this.children = kids;
}
setAttribute(name, value) {
this.attrs[name] = value;
}
getAttribute(name) {
return this.attrs[name];
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
(this.listeners.click || []).forEach((h) => h({ target: this }));
}
scrollIntoView() {
scrolled.push(this.dataset.id ?? "");
}
all() {
return this.children.flatMap((c) => (c instanceof FakeNode ? [c, ...c.all()] : []));
}
querySelector(selector) {
const name = selector.replace(/^\./, "");
return this.all().find((n) => n.classList.contains(name)) ?? null;
}
}
const scrolled = [];
const tableShows = [];
const detailShows = [];
let onColumn = null;
let modal = null;
const button = ({ label, variant, onClick }) => {
const node = new FakeNode({ className: `ui-btn ui-btn--${variant ?? "filled"}`, text: label });
if (onClick) node.addEventListener("click", onClick);
return node;
};
const STUBS = {
"./ui_template_item_picker.css": {},
"@config/config_frontend": { API_BASE_URL: "/api" },
"./ui_template_elements": {
el: (_tag, props) => new FakeNode(props),
createButton: button,
createInputField: () => {
const input = new FakeNode({ className: "ui-input" });
return { root: new FakeNode({ children: [input] }), input };
},
showToast: () => {},
},
"./ui_template_locale": { t: (key) => key },
"./ui_template_modal": {
openModal: (opts) => {
const body = new FakeNode({ className: "ui-modal__body" });
modal = { title: opts.title, body };
opts.mount(body);
return { close: () => {} };
},
},
// 나무 — 펼친(`open`) 갈래만 아래 줄을 그림 = 보이는 줄만 생김
"./ui_template_tree": {
renderTree: function render(nodes, make, depth = 0) {
return nodes.flatMap((node) => {
if (!node.children?.length) return [make(node, "", depth, () => node.run?.())];
const row = make(node, node.open ? "▾" : "▸", depth, () => {});
return [row, ...(node.open ? render(node.children, make, depth + 1) : [])];
});
},
createTreeRow: (node, _caret, _depth, onClick) => {
const row = new FakeNode({ className: "ui-tree__row", text: node.label });
row.dataset.id = node.id;
row.addEventListener("click", onClick);
return row;
},
openTreeMenu: () => {},
},
"./ui_template_item_picker_ask": { askFields: async () => null },
"./ui_template_item_picker_project": { projectItems: () => ({}) },
"./ui_template_item_picker_detail": {
createItemDetail: () => ({
root: new FakeNode({ className: "detail" }),
show: (row) => detailShows.push(row ? row.key : null),
markEdited: () => {},
reload: () => {},
}),
},
"./ui_template_item_picker_edit": { createEditView: () => ({}) },
"./ui_template_item_picker_table": {
createComboTable: (cb) => {
onColumn = cb;
return {
root: new FakeNode({ className: "table" }),
show: (at, column) =>
tableShows.push(at ? `${at.layer}|${at.owner ?? ""}|${column}` : null),
destroy: () => {},
};
},
},
};
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request in STUBS) return STUBS[request];
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const item = (key, 열, 경로) => ({ key, 열, 경로 });
const GROUPS = [
{
layer: "system",
owner: null,
items: [
item("SS1", "rvS1", ["돌기슭막이", "메쌓기", "1.2m"]),
item("SS2", "rvS2", ["돌기슭막이", "찰쌓기", "1.5m"]),
],
},
{ layer: "company", owner: "c1", items: [item("SC1", "rvC1", ["배수관", "흄관"])] },
{
layer: "personal",
owner: "u1",
items: [item("SP1", "rvP1", ["옹벽", "기초"]), item("SP2", "rvP2", ["옹벽", "벽체", "1m"])],
},
];
globalThis.fetch = async () => ({ ok: true, json: async () => GROUPS });
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
(async () => {
const { openItemPicker } = loadTs(path.join(UI, "ui_template_item_picker.ts"));
const out = {};
void openItemPicker({ current: null, canCopy: false });
await flush();
const [, cols] = modal.body.children;
const [search, layerBar, treeHost] = cols.children[0].children;
const searchInput = search.children[0];
const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id;
const leaves = () =>
treeHost
.all()
.filter((n) => n.dataset.id && n.dataset.id.split("|").length === 3)
.map((n) => n.dataset.id);
const layerButton = (key) => layerBar.children.find((b) => b.textContent === key);
const pressed = () =>
layerBar.children
.filter((b) => b.getAttribute("aria-pressed") === "true")
.map((b) => b.textContent);
out.title = modal.title;
out.start = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) };
// ① 위 표에서 열 고름(개인 층) → 나무 고름 · 펼쳐 보임 · 굴림 · 오른쪽 카드 · 표 열
scrolled.length = 0;
onColumn("rvP2");
out.tablePick = {
active: active(),
visible: leaves().includes("personal|u1|SP2"),
scrolled: scrolled.includes("personal|u1|SP2"),
detail: detailShows.at(-1),
table: tableShows.at(-1),
};
// 같은 열 다시 · 표에 없는 열 = 아무것도 안 바뀜(표 → 나무 → 표 되돌이 없음)
const before = { detail: detailShows.length, table: tableShows.length };
onColumn("rvP2");
onColumn("rvNone");
out.sameAgain = {
detail: detailShows.length - before.detail,
table: tableShows.length - before.table,
};
// ② 층 단추 → 위 표 = 그 층 표(고른 항목은 다른 층이라 열 없음) · 나무 = 그 층
layerButton("M02_PickerSystem").click();
out.layerSystem = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) };
layerButton("M02_PickerCompany").click();
out.layerCompany = { table: tableShows.at(-1), layer: pressed() };
// 고른 항목의 층으로 돌아오면 그 열이 다시 골라짐
layerButton("M02_PickerPersonal").click();
out.layerBack = tableShows.at(-1);
// ③ 다른 층 표에서 고름 — 접힌 폴더가 펼쳐지고 그 항목 · 카드 · 표 열
layerButton("M02_PickerSystem").click();
out.systemLeavesBefore = leaves();
onColumn("rvS2");
out.systemPick = {
active: active(),
visible: leaves().includes("system||SS2"),
detail: detailShows.at(-1),
table: tableShows.at(-1),
};
// ④ 나무 고름 → 위 표 열(지금 길 그대로)
onColumn("rvS1");
const leaf = treeHost.all().find((n) => n.dataset.id === "system||SS2");
leaf.click();
out.treePick = { active: active(), detail: detailShows.at(-1), table: tableShows.at(-1) };
// ⑤ 찾기에 가려진 항목을 표에서 고르면 찾기를 풂
searchInput.value = "찰쌓기";
(searchInput.listeners.input || []).forEach((h) => h());
onColumn("rvS1");
out.searchCleared = { value: searchInput.value, active: active() };
console.log(JSON.stringify(out));
})().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -215,14 +215,15 @@ const row = buildSummaryRow();
const clicks = [];
row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") });
const [labelNode, line] = row.root.children;
const [nameNode, pickBtn, clearBtn] = line.children;
// 280-1 — [선택] 단추 없음 · 이름 칸이 고르기
const [nameNode, clearBtn] = line.children;
out.rowEmpty = {
name: nameNode.value,
assigned: nameNode.classList.contains("is-assigned"),
clearDisabled: clearBtn.disabled,
get: row.get(),
label: labelNode.textContent,
pick: pickBtn.textContent,
buttons: line.children.filter((c) => c.tagName === "button").map((c) => c.textContent),
clear: clearBtn.textContent,
};
row.set(A);
@@ -234,8 +235,15 @@ out.rowSet = {
key: row.get().key,
nameShowsKey: nameNode.value.includes("SS000007"),
};
pickBtn.click();
nameNode.click();
clearBtn.click();
// 이름 칸 키 — Enter · Space = 고르기 · 다른 키는 그대로
const keys = [];
for (const key of ["Enter", " ", "a"])
(nameNode.listeners.keydown || []).forEach((h) =>
h({ key, preventDefault: () => keys.push(`prevent:${key}`) }),
);
out.keyClicks = { clicks: clicks.slice(2), prevented: keys };
out.clicks = clicks;
row.set(null);
out.rowCleared = { name: nameNode.value, clearDisabled: clearBtn.disabled };
@@ -115,8 +115,8 @@ def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙
def test_모달_배선_고르면_보이고_편집_화면에서는_숨김():
ts = _text(UI / "ui_template_item_picker.ts")
assert "createComboTable()" in ts
assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다
assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무
assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2)
assert "body.append(tableWrap, cols, editHost," in ts # 맨 위
assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때
assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""PLAN 280 — B05 · B06 구조물조합 칸 · 「구조물 조합 선택」 모달.
280-1 이름 칸 = 고르기 단추 노릇(누름 · Enter · Space · 손 커서 · 풍선 · 초점 테두리) · [선택] 없음 · [해제] 그대로 ·
본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기가 같은 줄 부품.
280-2 모달 제목 · 위 표 고름 → 나무(펼쳐 굴림) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층(Node · 대역).
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
UI = ROOT / "ui_template"
B05 = ROOT / "B05_Profile"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _node(helper: str) -> dict:
proc = subprocess.run(
["node", str(HERE / helper)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def row():
return _node("helper_b05_structures_form_summary.cjs")
@pytest.fixture(scope="module")
def picker():
return _node("helper_280_1_combo_pick.cjs")
@pytest.fixture(scope="module")
def table():
return _node("helper_224_1_picker_top_table.cjs")
# ── 280-1 ────────────────────────────────────────────────────────────────
def test_이름_칸_누름_Enter_Space_고르기_선택_단추_없음_해제_있음(row):
assert row["rowEmpty"]["buttons"] == ["해제"]
assert row["clicks"][:2] == ["pick", "clear"]
assert row["keyClicks"] == {
"clicks": ["pick", "pick"],
"prevented": ["prevent:Enter", "prevent: "],
}
# 고른 뒤 · 해제 뒤 이름 칸 글 바뀜 그대로
assert row["rowSet"]["name"] == "돌기슭막이 메쌓기 1.2m"
assert row["rowCleared"] == {"name": "미지정", "clearDisabled": True}
def test_이름_칸_풍선_초점_손_커서():
src = _text(B05 / "B05_Profile_UI_Structures_Summary_Row.ts")
assert 'const PICK_TIP = "눌러서 고르기";' in src
assert "name.title = `${summaryTooltip(item)} · ${PICK_TIP}`;" in src
assert "name.tabIndex = -1;" not in src # Tab 으로 초점이 감
assert '"선택"' not in src
css = _text(B05 / "B05_Profile_UI_Style_Structures.css")
base = re.search(r"\.b05-structure__summary-name \{([^}]*)\}", css).group(1)
assert "cursor: pointer;" in base
focus = re.search(r"\.b05-structure__summary-name:focus-visible \{([^}]*)\}", css).group(1)
assert "outline:" in focus
def test_같은_꼴_줄_모두_한_부품():
# 본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기 = 같은 `buildSummaryRow`
for path in (
B05 / "B05_Profile_UI_Structures_Form.ts",
B05 / "B05_Profile_UI_Structures_Groups.ts",
ROOT / "A00_Common" / "design" / "design_structure_form.ts",
):
assert "buildSummaryRow()" in _text(path), path.name
# ── 280-2 ────────────────────────────────────────────────────────────────
def test_모달_제목():
locale = _text(UI / "ui_template_locale_m2.ts")
assert 'M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"],' in locale
assert 'title: t("M02_PickerTitle"),' in _text(UI / "ui_template_item_picker.ts")
def test_처음_위_표_보이는_층_표(picker):
assert picker["start"] == {
"table": "personal|u1|",
"layer": ["M02_PickerPersonal"],
"detail": None,
}
def test_위_표_고름_나무_펼쳐_고름_오른쪽_카드(picker):
assert picker["tablePick"] == {
"active": "personal|u1|SP2",
"visible": True,
"scrolled": True,
"detail": "SP2",
"table": "personal|u1|rvP2",
}
# 같은 열 · 표에 없는 열 = 안 바뀜(표 → 나무 → 표 되돌이 없음)
assert picker["sameAgain"] == {"detail": 0, "table": 0}
# 접힌 폴더 속 항목도 펼쳐서 고름
assert picker["systemLeavesBefore"] == []
assert picker["systemPick"] == {
"active": "system||SS2",
"visible": True,
"detail": "SS2",
"table": "system||rvS2",
}
def test_층_단추_위_표_그_층(picker):
assert picker["layerSystem"] == {
"table": "system||",
"layer": ["M02_PickerSystem"],
"detail": "SP2", # 고른 항목은 그대로
}
assert picker["layerCompany"] == {"table": "company|c1|", "layer": ["M02_PickerCompany"]}
assert picker["layerBack"] == "personal|u1|rvP2" # 고른 항목 층으로 오면 그 열
def test_나무_고름_위_표_열_지금_길(picker):
assert picker["treePick"] == {
"active": "system||SS2",
"detail": "SS2",
"table": "system||rvS2",
}
def test_찾기에_가려진_항목_표에서_고르면_찾기_풂(picker):
assert picker["searchCleared"] == {"value": "", "active": "system||SS1"}
def test_위_표_열_고름은_부른_쪽에_층만_바꾸면_열_없이(table):
assert table["onColumn"] == ["rv2"]
assert table["layerOnly"]["reads"] == [
["readTemplate", "system", "table", "구조물집계표", None, None]
]
@@ -84,11 +84,16 @@ def test_집계_항목_줄(out):
True,
None,
)
assert (empty["label"], empty["pick"], empty["clear"]) == ("구조물 조합", "선택", "해제")
# 280-1 — [선택] 단추 없음(이름 칸이 고르기) · [해제] 그대로
assert (empty["label"], empty["buttons"], empty["clear"]) == ("구조물 조합", ["해제"], "해제")
row = out["rowSet"]
assert row["assigned"] is True and row["clearDisabled"] is False
assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"]
assert out["clicks"] == ["pick", "clear"]
assert out["clicks"][:2] == ["pick", "clear"] # 이름 칸 누름 = 고르기
assert out["keyClicks"] == {
"clicks": ["pick", "pick"],
"prevented": ["prevent:Enter", "prevent: "],
}
assert out["rowCleared"] == {"name": "미지정", "clearDisabled": True}
+31 -2
View File
@@ -10,6 +10,7 @@
* 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정).
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
* 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목.
* ========================================================================== */
import "./ui_template_item_picker.css";
@@ -24,6 +25,7 @@ import {
ownerOf,
pathIds,
rowId,
tableKey,
toSummary,
type PickNode,
type PickerLayer,
@@ -148,7 +150,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
// 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸)
onCreated: (created) => void load(created.key),
});
const comboTable = createComboTable();
const comboTable = createComboTable((colId) => pickColumn(colId));
const cols = el("div", {
className: "ui-item-picker__cols",
children: [
@@ -179,9 +181,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
const hint = (text: string): HTMLElement =>
el("p", { className: "ui-item-picker__hint", text });
/** 위 표의 기준 줄 — 고른 항목이 보이는 층이면 그것 · 아니면 그 층 아무 줄(주인을 앎) */
const tableRow = (): PickerRow | undefined =>
chosen && layerOf(chosen) === layer ? chosen : rows.find((row) => layerOf(row) === layer);
/** 위 표 = 보이는 층(+ 주인) 표 · 고른 항목이 그 층이면 그 열 — 목록을 읽기 전엔 숨김 */
const paintTable = (): void => {
const row = tableRow();
comboTable.show(
rows.length ? { layer, owner: row ? ownerOf(row) : null } : null,
row === chosen && row ? row.열 : "",
);
};
const paintDetail = (): void => {
detailView.show(chosen);
comboTable.show(chosen);
paintTable();
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
@@ -232,6 +247,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
onClick: () => {
layer = one;
paintTree();
paintTable();
},
});
button.setAttribute("aria-pressed", String(one === layer));
@@ -297,6 +313,19 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
};
/** 위 표에서 고른 열 → 그 항목을 나무에서 펼쳐 고름 · 카드도(같은 항목이면 그대로) */
function pickColumn(colId: string): void {
const base = tableRow();
const row = base && rows.find((one) => tableKey(one) === tableKey(base) && one.열 === colId);
if (!row || (chosen && rowId(row) === rowId(chosen))) return;
chosen = row;
for (const id of pathIds(row)) opened.add(id);
if (!filterRows([row], search.input.value).length) search.input.value = ""; // 찾기에 가려지면 풂
paintTree();
paintDetail();
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
}
search.input.addEventListener("input", paintTree);
// 빈 자리 오른쪽 클릭 = 고른 층 맨 위 메뉴(개인만 「새 항목」 · 줄 위는 줄 메뉴)
treeHost.addEventListener("contextmenu", (event) => {
+20 -15
View File
@@ -2,8 +2,8 @@
* ui_template_item_picker_table.ts
* 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품**
* (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임.
* 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐.
* 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜.
* 보기 전용(`readOnly` · 고침은 M02 에서) · 보이는 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐(280-2).
* 같은 층이면 표는 그대로 두고 열만 옮김 · 층이 바뀌면 그 층 표로 바뀜 · 표에서 열을 고르면 `onColumn`.
* 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임).
* ========================================================================== */
@@ -12,14 +12,14 @@ import {
fetchStructureNumbersAt,
readTemplate,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data";
import type { PickerLayer } from "./ui_template_item_picker_data";
import { t } from "./ui_template_locale";
import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet";
export interface ComboTable {
root: HTMLElement;
/** 고른 항목 — `null` 이면 표를 숨김 */
show: (row: PickerRow | null) => void;
/** 보일 층(+ 주인) · 고를 열(빈 글 = 안 고름) — `null` 이면 표를 숨김 */
show: (at: { layer: PickerLayer; owner: string | null } | null, column: string) => void;
destroy: () => void;
}
@@ -29,7 +29,8 @@ const failed = (error: unknown): string =>
error instanceof Error ? error.message : String(error),
);
export function createComboTable(): ComboTable {
/** `onColumn` = 표에서 고른 열 id(누르기 · 방향키) */
export function createComboTable(onColumn: (colId: string) => void): ComboTable {
const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } });
let seq = 0;
let shownKey: string | null = null;
@@ -43,14 +44,17 @@ export function createComboTable(): ComboTable {
shownKey = null;
};
async function paint(row: PickerRow, key: string, mine: number): Promise<void> {
async function paint(
layer: PickerLayer,
owner: string | null,
key: string,
mine: number,
): Promise<void> {
drop();
root.replaceChildren(
el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }),
);
try {
const layer = layerOf(row);
const owner = ownerOf(row);
const [{ createSheet }, struct] = await Promise.all([
import("./sheet/ui_template_sheet"),
import("../M02_MasterTemplete/M02_MasterTemplete_Structure"),
@@ -65,6 +69,7 @@ export function createComboTable(): ComboTable {
readOnly: true,
lockHead: true,
combo: {},
onSelect: (sel) => sel.colId && onColumn(sel.colId),
...struct.numberLabels(() => numbers),
});
shownKey = key;
@@ -77,14 +82,14 @@ export function createComboTable(): ComboTable {
return {
root,
show: (row) => {
show: (at, col) => {
const mine = ++seq;
root.hidden = !row;
if (!row) return;
column = row.열;
const key = tableKey(row);
root.hidden = !at;
if (!at) return;
column = col;
const key = `${at.layer}|${at.owner ?? ""}`;
if (sheet && key === shownKey) return sheet.selectColumn(column);
void paint(row, key, mine);
void paint(at.layer, at.owner, key, mine);
},
destroy: () => {
seq += 1;
+1 -1
View File
@@ -70,7 +70,7 @@ export const ui_locales_m2 = {
"Register a company first to manage structure templates",
],
M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"],
M02_PickerTitle: ["항목 고르기", "Pick an item"],
M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"],
M02_PickerSearch: ["항목 찾기", "Search items"],
M02_PickerSystem: ["마스터", "Master"],
M02_PickerCompany: ["회사", "Company"],