Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/* 202-1 — 표 칸 아래로 간 안내 줄 몫이 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)에 들어가는지 단위검증 헬퍼.
|
||||
* 카드 본문 = [표 칸][아래 줄][안내 줄 · 안내 줄 …](B08 `tableCard` 꼴) 가짜 DOM 에서 돌리고 JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
const win = { innerHeight: 900, scrollY: 0, addEventListener() {}, removeEventListener() {} };
|
||||
const observers = [];
|
||||
class RO {
|
||||
constructor(fn) {
|
||||
this.fn = fn;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const el = (name, css = {}, height = 0) => ({
|
||||
name,
|
||||
css,
|
||||
height,
|
||||
top: 0,
|
||||
parentElement: null,
|
||||
nextElementSibling: null,
|
||||
isConnected: true,
|
||||
style: {},
|
||||
getBoundingClientRect() {
|
||||
return { top: this.top, height: this.height };
|
||||
},
|
||||
});
|
||||
function link(parent, ...kids) {
|
||||
kids.forEach((k, i) => {
|
||||
k.parentElement = parent;
|
||||
k.nextElementSibling = kids[i + 1] ?? null;
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
const sty = (n) => ({
|
||||
rowGap: n.css.rowGap ?? "normal",
|
||||
paddingBottom: n.css.paddingBottom ?? "0px",
|
||||
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
|
||||
marginBottom: n.css.marginBottom ?? "0px",
|
||||
});
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)(
|
||||
box.exports,
|
||||
box,
|
||||
(p) => (p.endsWith(".css") ? {} : require(p)),
|
||||
win,
|
||||
{ body: null },
|
||||
sty,
|
||||
RO,
|
||||
);
|
||||
const m = box.exports;
|
||||
|
||||
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40]
|
||||
const body = el("body", { paddingBottom: "8px" });
|
||||
const card = el("card", { paddingBottom: "12px", rowGap: "8px" });
|
||||
const root = el("root");
|
||||
const avg = el("avg", {}, 20);
|
||||
const info = el("info", {}, 30);
|
||||
const hint = el("hint", {}, 40);
|
||||
link(body, card);
|
||||
link(card, root, avg, info, hint);
|
||||
root.top = 200;
|
||||
|
||||
m.fillViewport(root);
|
||||
observers.forEach((o) => o.fn());
|
||||
const out = { first: parseFloat(root.style.maxHeight) };
|
||||
out.below = m.spaceBelow(root);
|
||||
out.belowExpected = 3 * 8 + 20 + 30 + 40 + 12 + 8; // 줄 사이 8 셋 · 아래 줄 · 안내 둘 · 카드 · 본문 여백
|
||||
out.bottom = root.top + out.first + out.below; // 문서 맨 아래 = 창 아래 끝(페이지 스크롤 0)
|
||||
out.viewport = win.innerHeight;
|
||||
|
||||
// 안내 줄이 두 줄로 늘어남 → 표 칸이 그만큼 줄어듦
|
||||
hint.height = 60;
|
||||
observers.forEach((o) => o.fn());
|
||||
out.afterHintGrow = parseFloat(root.style.maxHeight);
|
||||
out.firstMinusGrow = out.first - 20;
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -27,5 +27,7 @@ def test_자재_셋_값_없는_줄_숨김():
|
||||
|
||||
|
||||
def test_표_넷_공용_카드_안():
|
||||
assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4)
|
||||
assert "createCard({ body: parts })" in SUMMARY
|
||||
assert (
|
||||
"createCard({ body: [toolbar, table.root, excel, notes] })" in MATERIAL
|
||||
) # + 엑셀 알림(160-4) · 안내 = 아래(202-1)
|
||||
assert "createCard({ body: [table, ...parts] })" in SUMMARY
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
|
||||
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김)
|
||||
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
|
||||
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표
|
||||
· B08 자재만 줄 · 출처 빈칸
|
||||
@@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다():
|
||||
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
|
||||
]
|
||||
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
|
||||
assert names[0] == "연결 점검"
|
||||
assert names[1:] == EXCEL_TABS
|
||||
assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김
|
||||
|
||||
|
||||
def test_길_셋():
|
||||
|
||||
@@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
|
||||
out = _node(
|
||||
f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});"
|
||||
"\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name),"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null),"
|
||||
" lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));"
|
||||
)
|
||||
assert out["names"] == [
|
||||
"제 1 호표 찰쌓기",
|
||||
" ㄴ 건설표준품셈 7-1-2",
|
||||
" ㄴ 석공",
|
||||
" ㄴ 뒷채움",
|
||||
" 합 계",
|
||||
]
|
||||
"ㄴ 건설표준품셈 7-1-2",
|
||||
"ㄴ 석공",
|
||||
"ㄴ 뒷채움",
|
||||
"합 계",
|
||||
] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰)
|
||||
assert out["cls"] == ["is-title", None, None, None, "is-title"]
|
||||
assert out["lv"] == ["name", 1, 2, 2, 2, 1]
|
||||
|
||||
|
||||
SG = {
|
||||
@@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸():
|
||||
rows = out["rows"]
|
||||
assert [(r.get("kind"), r["text"]) for r in rows] == [
|
||||
("제 1 호표", "뒷채움 / m3"),
|
||||
(None, " 산림품셈 13-3-2 적용"),
|
||||
(None, " 규격 : 150 mm"),
|
||||
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
|
||||
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
(None, "산림품셈 13-3-2 적용"),
|
||||
(None, "규격 : 150 mm"),
|
||||
("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합
|
||||
("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
|
||||
]
|
||||
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
|
||||
|
||||
@@ -229,8 +229,9 @@ console.log(JSON.stringify(out));
|
||||
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
|
||||
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
|
||||
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
|
||||
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
|
||||
above = out["above"]
|
||||
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
|
||||
|
||||
@@ -142,21 +142,21 @@ def test_화면_보기_바꿈_누름_이동():
|
||||
out = run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
"\nconst gone = []; let redraws = 0; const focused = [];"
|
||||
"\nconst gone = []; let redraws = 0; const focused = []; const calls = [];"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l),"
|
||||
" redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table'),"
|
||||
" redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table'),"
|
||||
" focusRow: (i) => focused.push(i) }) };"
|
||||
"\nconst [view] = renderGroupPanel(o);"
|
||||
"\nconst btn = view.querySelector('[data-b09-group-view]');"
|
||||
"\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);"
|
||||
"\nconst label = btn.textContent;"
|
||||
"\nview.querySelector('[data-at=\"2\"]').click();"
|
||||
"\nview.querySelector('[data-at=\"0\"]').click();"
|
||||
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
|
||||
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
|
||||
"\nbtn.click();"
|
||||
"\nconst [bar2, view2] = renderGroupPanel(o);"
|
||||
"\nconst [view2] = renderGroupPanel(o);"
|
||||
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
|
||||
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
|
||||
" label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused,"
|
||||
" kept: Object.fromEntries(kept) }));",
|
||||
RAW,
|
||||
)
|
||||
@@ -171,7 +171,11 @@ def test_화면_보기_바꿈_누름_이동():
|
||||
|
||||
def test_잇기():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "if (isGroupTab(tab)) {" in shell and "renderGroupPanel({ tab, spec," in shell
|
||||
assert (
|
||||
"if (isGroupTab(tab)) {" in shell
|
||||
and "renderGroupPanel({" in shell
|
||||
and "setTools: bars.setTools" in shell
|
||||
)
|
||||
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text("utf-8")
|
||||
assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state
|
||||
assert '"b09-group-cols": { bucket: "pref", scope: "global" },' in state
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
|
||||
|
||||
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
|
||||
EXPECTED = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
@@ -31,7 +30,7 @@ EXPECTED = [
|
||||
]
|
||||
|
||||
|
||||
def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t),"
|
||||
@@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
)
|
||||
assert out["groups"] == EXPECTED
|
||||
flat = [t for _, tabs in out["groups"] for t in tabs]
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18
|
||||
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
|
||||
firsts = list(dict.fromkeys(out["of"]))
|
||||
assert firsts == [g[0] for g in EXPECTED]
|
||||
assert len(firsts) == 10
|
||||
assert len(firsts) == 9
|
||||
|
||||
|
||||
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
|
||||
@@ -55,32 +54,32 @@ const bars = createTabBars((t) => picks.push(t));
|
||||
const view = () => ({
|
||||
top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'],
|
||||
b.dataset.b09Group, b.dataset.b09Tab ?? null]),
|
||||
sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
|
||||
sub: bars.sub.children.filter((b) => !b.classList.contains('b09-estimation__tools')).map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
|
||||
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
|
||||
role: bars.sub.attrs.role,
|
||||
});
|
||||
bars.draw('dg');
|
||||
const a = view();
|
||||
bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.sub.children[0].click(); // 일위대가목록표
|
||||
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
|
||||
const b = view();
|
||||
bars.draw('sum_material');
|
||||
bars.draw('dg');
|
||||
bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.draw('cost');
|
||||
const c = view();
|
||||
bars.top.children[0].click(); // 연결 점검 = 단독 위 탭
|
||||
bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭
|
||||
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
|
||||
console.log(JSON.stringify({ a, b, c, picks }));
|
||||
""",
|
||||
)
|
||||
a, b, c = out["a"], out["b"], out["c"]
|
||||
assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [
|
||||
"연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가"
|
||||
assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [
|
||||
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출"
|
||||
] # fmt: skip
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5
|
||||
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
|
||||
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
|
||||
assert not a["blank"] and a["role"] == "tablist"
|
||||
@@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks }));
|
||||
["자재단가대비표", "true", "compare"],
|
||||
["재료비수량금액집계표", "false", "sum_material"],
|
||||
] # fmt: skip
|
||||
assert b["top"][8][2] == "true" and b["top"][4][2] == "false"
|
||||
assert b["top"][7][2] == "true" and b["top"][3][2] == "false"
|
||||
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
|
||||
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
|
||||
assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "check"]
|
||||
assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "total"]
|
||||
|
||||
|
||||
def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙():
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_표_보기_줄_class_넘김():
|
||||
[COMMON, TABS, GROUPS],
|
||||
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
|
||||
"\nlet extra = null;"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {},"
|
||||
" makeTable: (e) => { extra = e; return { root: document.createElement('table'),"
|
||||
" focusRow: () => {} }; } };"
|
||||
"\nchosen.set('dg', 'table');"
|
||||
|
||||
@@ -281,6 +281,7 @@ const out = {
|
||||
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
|
||||
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
|
||||
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
|
||||
lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))],
|
||||
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
|
||||
};
|
||||
table.onCell(at("hand:a"), "connect");
|
||||
@@ -309,7 +310,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
|
||||
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
"is-title",
|
||||
"is-group is-hand",
|
||||
"is-hand",
|
||||
None,
|
||||
] # fmt: skip 201-1 레벨 = 공용 표 level
|
||||
assert out["lv"] == ["name", 1, 3, 4, 3]
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = [
|
||||
"일위대가 호표",
|
||||
|
||||
@@ -125,10 +125,13 @@ def test_메뉴_모든_줄():
|
||||
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
||||
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
||||
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
||||
"내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
||||
"아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1).
|
||||
|
||||
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
|
||||
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
|
||||
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
|
||||
들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply
|
||||
BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
"""
|
||||
|
||||
@@ -42,39 +42,35 @@ def _run() -> dict:
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
|
||||
assert all(rows[k] == (1, "is-title") for k in top)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
assert {lv for lv, _ in rows.values()} == {1, 2}
|
||||
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
assert rows[k] == (1, "is-lv1 is-title"), k
|
||||
assert rows[k] == (1, "is-title"), k
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
assert rows[k] == (3, "is-lv3"), k
|
||||
assert rows[k] == (3, None), k
|
||||
|
||||
|
||||
def test_칸_class_명칭만_들여씀():
|
||||
def test_명칭_칸만_들여씀_공용_level():
|
||||
out = _run()
|
||||
for tab in ("total", "cost"):
|
||||
for _k, _lv, _cls, name, first, amount in out[tab]:
|
||||
assert (name, first, amount) == ("is-indent", "is-flat", None)
|
||||
for _k, lv, _cls, key, of, cell in out[tab]:
|
||||
assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음
|
||||
|
||||
|
||||
def test_CSS_레벨_규칙():
|
||||
def test_CSS_옛_레벨_규칙_없음_공용_표로():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
|
||||
assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css
|
||||
common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8")
|
||||
assert re.search(
|
||||
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
|
||||
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);",
|
||||
common,
|
||||
)
|
||||
for lv, em in ((2, 1), (3, 2)):
|
||||
assert re.search(
|
||||
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
|
||||
css,
|
||||
)
|
||||
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
|
||||
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""185-2 B09 묶음 탭 도구 줄 — 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
|
||||
"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
|
||||
|
||||
- 카드 보기 = 보기 바꿈 단추가 가로 열 수 줄(`ui-group-cards__bar`) 안 · 표 보기 = 머리 줄(`b09-estimation__bar`)
|
||||
- 단추 · 라벨 = `white-space: nowrap` · 도구 줄 = 줄바꿈 없음(`flex-wrap: nowrap`)
|
||||
- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만
|
||||
- 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -27,40 +27,43 @@ def _block(css: str, selector: str) -> str:
|
||||
|
||||
|
||||
def _panel(view: str, hint: str):
|
||||
"""고른 보기 · 안내 글로 그린 뒤 보기 바꿈 단추가 든 줄 · 그 줄의 열 수 단추."""
|
||||
"""고른 보기 · 안내 글로 그린 뒤 도구 묶음(보기 바꿈 단추 · 열 수 단추) · 판 안 머리 띠."""
|
||||
return run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
f"\nchosen.set('dg', '{view}');"
|
||||
"\nconst tools = [];"
|
||||
f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}},"
|
||||
" setTools: (t) => tools.push(...t),"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
|
||||
"\nconst out = renderGroupPanel(o);"
|
||||
"\nconst top = out.find((e) => e.all('[data-b09-group-view]').length || e.dataset.b09GroupView);"
|
||||
"\nconst btn = top.dataset.b09GroupView ? top : top.querySelector('[data-b09-group-view]');"
|
||||
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar') ?? top;"
|
||||
"\nconst btn = tools.find((e) => e.dataset.b09GroupView);"
|
||||
"\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;"
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent,"
|
||||
" bar: bar.className, cols: bar.all('[data-group-cols]').map((b) => b.dataset.groupCols) }));",
|
||||
" cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols),"
|
||||
" barBtn: bar ? bar.all('[data-b09-group-view]').length : null,"
|
||||
" barCols: bar ? bar.all('[data-group-cols]').length : null }));",
|
||||
RAW,
|
||||
)
|
||||
|
||||
|
||||
def test_카드_보기_단추가_가로_열수_줄에():
|
||||
def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에():
|
||||
out = _panel("cards", "null")
|
||||
assert out["bar"] == "ui-group-cards__bar" and out["label"] == "표로 보기"
|
||||
assert out["cols"] == ["1", "2", "3", "4"] # 같은 줄에 열 수 단추 넷
|
||||
assert out["label"] == "표로 보기"
|
||||
assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷
|
||||
assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음
|
||||
assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐
|
||||
|
||||
|
||||
def test_카드_보기_안내_글은_같은_도구_줄에():
|
||||
# 200-1 — 안내 글이 카드 위 따로 줄에서 도구 줄 안으로(상세 = test_200_1)
|
||||
def test_카드_보기_안내_글은_카드_판_아래에():
|
||||
# 202-1 — 안내 글 = 카드 판 아래(상세 = test_202_1)
|
||||
out = _panel("cards", HINT)
|
||||
assert out["n"] == 1 and out["first"] == "ui-group-cards"
|
||||
assert out["bar"] == "ui-group-cards__bar"
|
||||
assert out["n"] == 2 and out["first"] == "ui-group-cards"
|
||||
|
||||
|
||||
def test_표_보기_단추는_머리_줄():
|
||||
def test_표_보기_단추는_도구_묶음에():
|
||||
out = _panel("table", "null")
|
||||
assert out["bar"] == "b09-estimation__bar" and out["label"] == "박스보기"
|
||||
assert out["label"] == "박스보기" and out["n"] == 1
|
||||
assert out["cols"] == [] # 표 보기 = 열 수 단추 없음
|
||||
|
||||
|
||||
@@ -73,5 +76,9 @@ def test_CSS_한_줄_nowrap():
|
||||
)
|
||||
assert "white-space: nowrap" in item and "flex: none" in item
|
||||
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert "white-space: nowrap" in _block(page, ".b09-estimation__bar .ui-btn")
|
||||
assert "flex-wrap: nowrap" in _block(page, ".b09-estimation__tools")
|
||||
tools = _block(
|
||||
page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label"
|
||||
)
|
||||
assert "white-space: nowrap" in tools
|
||||
assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""200-1 B09 박스보기 도구 줄 한 줄 · 카드 같은 가로 줄 높이 같게.
|
||||
"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게.
|
||||
|
||||
- 안내 글 · 가로 열 수 단추 · 보기 단추 = 같은 줄(카드 보기 = `ui-group-cards__bar` · 표 보기 = `b09-estimation__bar`)
|
||||
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문
|
||||
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
|
||||
- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래
|
||||
- 카드 제목 줄 · 합계 줄 바탕색(200-3)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -32,43 +32,36 @@ def _block(css: str, selector: str) -> str:
|
||||
|
||||
|
||||
def _panel(view: str, hint: str):
|
||||
"""고른 보기 · 안내 글로 그린 뒤 도구 줄 구성 · 호버 기록."""
|
||||
"""고른 보기 · 안내 글로 그린 뒤 안내 글 자리 · 호버 기록."""
|
||||
return run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
f"\nchosen.set('dg', '{view}');"
|
||||
f"\nconst hint = {hint};"
|
||||
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
|
||||
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {},"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
|
||||
"\nconst out = renderGroupPanel(o);"
|
||||
"\nconst btn = out.flatMap((e) => (e.dataset.b09GroupView ? [e] : e.all('[data-b09-group-view]')))[0];"
|
||||
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar');"
|
||||
"\nconst h = hint ? bar.all('.b09-estimation__line-hint')[0] : null;"
|
||||
"\nconst last = out[out.length - 1];"
|
||||
"\nconst inBar = hint ? out.some((e) => e.all('.ui-group-cards__bar').some((b) => b.contains(hint))) : false;"
|
||||
"\nconst call = hoverCalls[0];"
|
||||
"\nconst wide = call ? call.text({ scrollWidth: 900, clientWidth: 300, textContent: 'T' }) : null;"
|
||||
"\nconst fit = call ? call.text({ scrollWidth: 300, clientWidth: 300, textContent: 'T' }) : null;"
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, bar: bar.className,"
|
||||
" cols: bar.all('[data-group-cols]').length, hint: !!h, hintInBar: h ? bar.contains(h) : false,"
|
||||
" hintCls: h ? h.className : '', hover: hoverCalls.length, hoverIsHint: call ? call.root === h : false,"
|
||||
" wide, fit, outer: out.map((e) => e.className) }));",
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, hintLast: !!hint && last === hint, inBar,"
|
||||
" hintCls: hint ? hint.className : '', hover: hoverCalls.length,"
|
||||
" hoverIsHint: call ? call.root === hint : false, wide, fit, first: out[0].tagName }));",
|
||||
RAW,
|
||||
)
|
||||
|
||||
|
||||
def test_카드_보기_안내_글이_단추와_같은_줄():
|
||||
def test_카드_보기_안내_글은_카드_판_아래():
|
||||
out = _panel("cards", HINT)
|
||||
assert out["n"] == 1 # 안내 글 따로 줄 없음 · 카드 판 하나
|
||||
assert out["bar"] == "ui-group-cards__bar"
|
||||
assert out["hint"] and out["hintInBar"] # 열 수 단추 · 보기 단추와 같은 도구 줄 안
|
||||
assert out["cols"] == 4
|
||||
assert out["n"] == 2 and out["hintLast"] and not out["inBar"] # 카드 판 + 안내 글(맨 아래)
|
||||
assert "b09-estimation__line-hint" in out["hintCls"]
|
||||
|
||||
|
||||
def test_표_보기_안내_글이_보기_단추와_같은_줄():
|
||||
def test_표_보기_안내_글은_표_아래():
|
||||
out = _panel("table", HINT)
|
||||
assert out["n"] == 2 # 머리 줄 + 표
|
||||
assert out["bar"] == "b09-estimation__bar"
|
||||
assert out["hint"] and out["hintInBar"]
|
||||
assert out["n"] == 2 and out["first"] == "TABLE" and out["hintLast"] # 표 + 안내 글
|
||||
|
||||
|
||||
def test_안내_글_잘렸을_때만_공용_호버로_전문():
|
||||
@@ -82,7 +75,7 @@ def test_안내_글_잘렸을_때만_공용_호버로_전문():
|
||||
def test_안내_글_없으면_호버도_없음():
|
||||
for view in ("cards", "table"):
|
||||
out = _panel(view, "null")
|
||||
assert out["hint"] is False and out["hover"] == 0
|
||||
assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0
|
||||
|
||||
|
||||
def test_CSS_안내_글_줄임표_한_줄():
|
||||
@@ -95,10 +88,6 @@ def test_CSS_안내_글_줄임표_한_줄():
|
||||
"min-width: 0",
|
||||
):
|
||||
assert need in hint, need
|
||||
assert "flex: 1 1 0" in hint
|
||||
# 표 보기 머리 줄 = 줄바꿈 없음 · 같은 높이 가운데
|
||||
bars = re.findall(r"\.b09-estimation__bar\s*\{([^}]*)\}", page)
|
||||
assert any("flex-wrap: nowrap" in b and "align-items: center" in b for b in bars)
|
||||
|
||||
|
||||
def test_CSS_카드_같은_줄_높이_같게():
|
||||
@@ -112,10 +101,9 @@ def test_CSS_카드_같은_줄_높이_같게():
|
||||
|
||||
def test_잇기():
|
||||
groups = (ROOT / GROUPS).read_text("utf-8")
|
||||
assert "lead," in groups and "actions: [toggle]" in groups
|
||||
assert "attachHoverInfo(hint," in groups
|
||||
assert "attachHoverInfo(hint," in groups and "placeTools: o.setTools" in groups
|
||||
cards = (ROOT / COMMON).read_text("utf-8")
|
||||
assert "if (opts.lead) bar.append(opts.lead);" in cards
|
||||
assert "opts.lead" not in cards and "if (opts.placeTools) {" in cards
|
||||
|
||||
|
||||
# ── 200-3 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ─────────────────────────
|
||||
|
||||
@@ -122,7 +122,8 @@ out.keyAct = [
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"]
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
|
||||
|
||||
|
||||
@@ -168,40 +169,22 @@ def test_키_셋_편집_중엔_안_먹음():
|
||||
# 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작
|
||||
assert out["outside"] == [False, []]
|
||||
assert out["unpick"] == [[True] * 4, False, False]
|
||||
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None]
|
||||
# 그냥 ↑ = 옮기기(204 방향키 · test_204_1)
|
||||
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None]
|
||||
|
||||
|
||||
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
bill = '.b09-estimation [data-b09-table="bill"]'
|
||||
# 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding
|
||||
for lv in range(2, 7):
|
||||
assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "")
|
||||
pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);"
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad),
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{")
|
||||
+ r"\s*"
|
||||
+ re.escape(pad),
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{')
|
||||
+ r"\s*padding-left: 0;",
|
||||
css,
|
||||
)
|
||||
# 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만)
|
||||
# 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌)
|
||||
assert "is-lv" not in css and "font-size: 13px" not in css
|
||||
src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
|
||||
assert 'level: { key: "name", of: (i) => rows[i]?.level' in src
|
||||
# 글 칸 왼쪽 맞춤(수량만 오른쪽)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;",
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css
|
||||
)
|
||||
assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음
|
||||
assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표.
|
||||
|
||||
- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음
|
||||
- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 ·
|
||||
합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호)
|
||||
- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
JS = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
|
||||
const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)));
|
||||
const m = box.exports;
|
||||
const body = (html) => html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
||||
const columns = [
|
||||
{ key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" },
|
||||
{ key: "v", label: "값", kind: "number" },
|
||||
];
|
||||
const rows = [
|
||||
{ g: "흙깍기", s: "가", n: "a", v: 1 },
|
||||
{ g: "흙깍기", s: "가", n: "b", v: 1 },
|
||||
{ g: "흙깍기", s: "나", n: "c", v: 1 },
|
||||
{ g: "운반", s: "나", n: "d", v: 2 },
|
||||
{ g: "운반", s: " ", n: "e", v: 2 },
|
||||
{ g: "", s: " ", n: "f", v: 2 },
|
||||
{ g: "", s: "다", n: "g", v: 3 },
|
||||
];
|
||||
const total = { g: "계", v: 12 };
|
||||
const out = {};
|
||||
out.plain = m.renderDataTableHtml({ columns, rows, total });
|
||||
const merged = { columns, rows, total, mergeSame: ["g", "s"] };
|
||||
out.merged = body(m.renderDataTableHtml(merged));
|
||||
out.mergedFoot = m.renderDataTableHtml(merged).split("<tfoot>")[1];
|
||||
out.spans = m.mergeSpans(merged, 0, rows.length);
|
||||
out.win = m.mergeSpans(merged, 1, 4);
|
||||
out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3);
|
||||
out.level = body(m.renderDataTableHtml({
|
||||
columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] },
|
||||
editable: (i, k) => i === 1 && k === "n",
|
||||
}));
|
||||
out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })];
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_옵션_없으면_무변():
|
||||
html = _run()["plain"]
|
||||
assert "rowspan" not in html.split("<tbody>")[1]
|
||||
assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html
|
||||
|
||||
|
||||
def test_mergeSame_같은_값만_rowspan():
|
||||
out = _run()
|
||||
# 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침
|
||||
assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1]
|
||||
assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1]
|
||||
assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로
|
||||
rows = out["merged"]
|
||||
assert '<td rowspan="3" data-c="0">흙깍기</td>' in rows[0].replace(' class="is-merged"', "")
|
||||
assert 'class="is-merged"' in rows[0]
|
||||
assert (
|
||||
rows[1].count("<td") == 2 and '<td data-c="2">b</td>' in rows[1]
|
||||
) # 합쳐진 칸 빠짐 · 열 번호
|
||||
assert '<td data-num="" data-c="3">2</td>' in rows[4] # 숫자 값 그대로
|
||||
assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침
|
||||
# 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침
|
||||
assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]]
|
||||
assert [s[0] for s in out["edit"]] == [1, 1, 1]
|
||||
assert out["cell"] == [3, 1]
|
||||
|
||||
|
||||
def test_level_보기_전용_들여쓰기():
|
||||
rows = _run()["level"]
|
||||
assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"]
|
||||
assert '<td class="ui-data-table__lv">a</td>' in rows[0]
|
||||
assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만
|
||||
assert '<td class="is-edit ui-data-table__lv" data-num="">' in rows[1] # 글 상자 칸도
|
||||
assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만)
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);"
|
||||
assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css)
|
||||
assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css)
|
||||
assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css)
|
||||
|
||||
|
||||
#: 적용 표 — [파일, 들어 있어야 할 글]
|
||||
APPLIED = [
|
||||
("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"),
|
||||
]
|
||||
|
||||
|
||||
def test_B08_B09_적용():
|
||||
for path, text in APPLIED:
|
||||
assert text in (ROOT / path).read_text("utf-8"), (path, text)
|
||||
# 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음)
|
||||
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
|
||||
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
|
||||
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
|
||||
assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
|
||||
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
|
||||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2
|
||||
@@ -0,0 +1,175 @@
|
||||
"""202-1 B08 · B09 표 칸 — 안내 · 경고 줄 = 맨 아래 · 박스보기 · 가로 열 수 = 보조 탭 줄 오른쪽.
|
||||
|
||||
- B09 안내 줄 = 표 · 카드 판 아래(마지막 자식) · 묶음 탭 도구(보기 단추 · 열 수) = 아래 탭 줄 오른쪽 · 단독 탭 빈 줄에서도
|
||||
- B08 안내 · 경고 줄 = 표 · 아래 줄 뒤 맨 아래(토공 · 운반 시트 · 자재 · 구조물 집계)
|
||||
- 창 끝까지 셈(199-1) = 아래로 간 안내 줄 몫 포함
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
|
||||
|
||||
B09 = "B09_Estimation/B09_Estimation_UI_"
|
||||
BAR = f"{B09}TabBar.ts"
|
||||
DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0}
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── B09 보조 탭 줄 오른쪽 도구 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_도구_자리_보조_줄_오른쪽_빈_줄에서도_보임():
|
||||
out = run(
|
||||
[TABS, BAR],
|
||||
"""
|
||||
const bars = createTabBars(() => {});
|
||||
const mk = (n) => { const b = document.createElement('button'); b.textContent = n; return b; };
|
||||
const snap = () => ({
|
||||
kids: bars.sub.children.map((c) => (c.classList.contains('b09-estimation__tools') ? 'TOOLS' : c.textContent)),
|
||||
last: bars.sub.children[bars.sub.children.length - 1].children.map((x) => x.textContent),
|
||||
});
|
||||
bars.draw('dg');
|
||||
bars.setTools([mk('가'), mk('나')]);
|
||||
const a = snap();
|
||||
bars.draw('compare'); // 다른 묶음으로 옮겨도 도구 자리는 그대로 마지막
|
||||
const b = snap();
|
||||
bars.draw('cost'); // 단독 탭 = 빈 보조 줄
|
||||
const c = snap();
|
||||
const ghost = bars.sub.children[0];
|
||||
bars.setTools([]);
|
||||
const d = snap();
|
||||
console.log(JSON.stringify({
|
||||
a: a.kids, aLast: a.last,
|
||||
b: b.kids.slice(-1), bLast: b.last,
|
||||
c: c.kids, cLast: c.last,
|
||||
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
|
||||
subHidden: bars.sub.attrs['aria-hidden'] ?? null, ghostHidden: ghost.attrs['aria-hidden'],
|
||||
dLast: d.last.length,
|
||||
}));
|
||||
""",
|
||||
)
|
||||
tools = "TOOLS"
|
||||
assert out["a"] == ["일위대가목록표", "일위대가표", tools] # 아래 탭 뒤 오른쪽 자리
|
||||
assert out["aLast"] == ["가", "나"]
|
||||
assert out["b"] == [tools] and out["bLast"] == ["가", "나"] # 탭을 옮겨도 도구는 그대로
|
||||
assert out["blank"] and len(out["c"]) == 2 and out["c"][1] == tools # 숨은 단추 + 도구
|
||||
assert out["cLast"] == ["가", "나"]
|
||||
assert out["subHidden"] is None and out["ghostHidden"] == "true" # 줄 전체가 아니라 숨은 단추만
|
||||
assert out["dLast"] == 0 # 비움
|
||||
|
||||
|
||||
def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
||||
"""쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음."""
|
||||
out = run(
|
||||
[COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
"\nconst bars = createTabBars(() => {});"
|
||||
"\nconst hint = document.createElement('p');"
|
||||
"\nconst res = {};"
|
||||
"\nfor (const view of ['cards', 'table']) {"
|
||||
"\n chosen.set('dg', view);"
|
||||
"\n bars.draw('dg'); bars.setTools([]);"
|
||||
"\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }),"
|
||||
" setTools: bars.setTools });"
|
||||
"\n const tools = bars.sub.children[bars.sub.children.length - 1];"
|
||||
"\n res[view] = { inRow: tools.all('[data-b09-group-view]').map((b) => b.textContent),"
|
||||
" cols: tools.all('[data-group-cols]').length,"
|
||||
" inPanel: panel.flatMap((e) => [e, ...e.all('[data-b09-group-view]'), ...e.all('[data-group-cols]')])"
|
||||
".filter((e) => e.dataset.b09GroupView || e.dataset.groupCols).length,"
|
||||
" hintLast: panel[panel.length - 1] === hint, n: panel.length };"
|
||||
"\n}"
|
||||
"\nconsole.log(JSON.stringify(res));",
|
||||
RAW,
|
||||
)
|
||||
assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4
|
||||
assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0
|
||||
for view in ("cards", "table"):
|
||||
assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음
|
||||
assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래)
|
||||
|
||||
|
||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert (
|
||||
"panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))"
|
||||
in shell
|
||||
) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침
|
||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||
assert "setTools: bars.setTools" in shell
|
||||
|
||||
|
||||
def test_CSS_도구_오른쪽_맞춤_빈_줄_숨김_규칙에_안_걸림():
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
tools = _block(css, ".b09-estimation__tools")
|
||||
assert "margin-left: auto" in tools and "flex-wrap: nowrap" in tools
|
||||
hidden = _block(css, ".b09-estimation__tabs--blank > .ui-btn")
|
||||
assert "visibility: hidden" in hidden # 직계 단추만 — 도구 묶음은 안 숨김
|
||||
assert ".b09-estimation__bar" not in css # 옛 머리 줄(안내 + 단추) 풂
|
||||
|
||||
|
||||
# ── B08 안내 · 경고 줄 맨 아래 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_B08_표_카드_차례_표_아래줄_안내():
|
||||
src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text("utf-8")
|
||||
fn = re.search(r"export function tableCard\(.*?\n}\n", src, re.S)
|
||||
assert fn
|
||||
code = fn.group(0).replace("export ", "")
|
||||
js = (
|
||||
"const createCard = ({ body }) => body;\n"
|
||||
+ code
|
||||
+ "\nconsole.log(JSON.stringify(tableCard(['i1', 'i2'], 'T', ['a1', 'a2'])));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=js,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["T", "a1", "a2", "i1", "i2"] # 표 · 아래 줄 · 안내 줄
|
||||
|
||||
|
||||
def test_B08_자재_구조물_안내_줄_표_아래():
|
||||
material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8")
|
||||
assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material
|
||||
assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래)
|
||||
summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8")
|
||||
assert "createCard({ body: [table, ...parts] })" in summary
|
||||
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8")
|
||||
assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar")
|
||||
|
||||
|
||||
# ── 창 끝까지 셈(199-1)에 아래 줄 몫 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_창_끝까지_셈에_아래_안내_줄_포함():
|
||||
proc = subprocess.run(
|
||||
["node", str(ROOT / "resources" / "tester" / "helper_202_1_fill_below.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
r = json.loads(proc.stdout)
|
||||
assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백
|
||||
assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
|
||||
assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦
|
||||
@@ -0,0 +1,112 @@
|
||||
"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번).
|
||||
|
||||
- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버)
|
||||
- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending)
|
||||
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
|
||||
from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402
|
||||
|
||||
CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts"
|
||||
SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
|
||||
|
||||
def test_묶음_이름_단추_글_설명_없음():
|
||||
assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL
|
||||
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
|
||||
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
|
||||
assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL
|
||||
assert "section.append(heading, refresh, check.list);" in SHELL
|
||||
assert "check.draw(payload, sheets);" in SHELL
|
||||
|
||||
|
||||
def test_연결_점검_탭_없음_첫_탭_공사원가계산서():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n),"
|
||||
" groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));",
|
||||
)
|
||||
assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"]
|
||||
assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서"
|
||||
assert "let current: TabId = TABS[0][0];" in SHELL
|
||||
assert "buildCheck" not in SHELL and "check:" not in SHELL
|
||||
|
||||
|
||||
def test_좌측_목록_이동_호버_배지():
|
||||
out = run(
|
||||
[TABS, CHECK],
|
||||
"""
|
||||
const moves = [];
|
||||
const refresh = document.createElement("button");
|
||||
const panel = createCheckPanel(refresh, (link) => moves.push(link));
|
||||
const sheets = { gaps: [
|
||||
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
|
||||
{ tab: "material", key: "MT9", name: "모래", why: "단가 없음" },
|
||||
] };
|
||||
const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2",
|
||||
notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } };
|
||||
panel.draw(payload, sheets);
|
||||
const view = () => ({
|
||||
pending: refresh.classList.contains("is-pending"),
|
||||
texts: panel.list.children.map((c) => c.text),
|
||||
rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]),
|
||||
});
|
||||
const a = view();
|
||||
const rowsBox = panel.list.children[1];
|
||||
rowsBox.children[1].click();
|
||||
const tipRefresh = hoverCalls.find((h) => h.root === refresh).text();
|
||||
const rowHover = hoverCalls.find((h) => h.root === panel.list);
|
||||
const tipRow = rowHover.text(rowsBox.children[0]);
|
||||
panel.draw({ book: null, stale: null }, { gaps: [] });
|
||||
const b = view();
|
||||
const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" });
|
||||
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty }));
|
||||
""",
|
||||
)
|
||||
a, b = out["a"], out["b"]
|
||||
assert a["pending"] and not b["pending"]
|
||||
assert a["texts"][0] == "연결 없음 2곳"
|
||||
assert a["rows"] == [
|
||||
["0", "ui-sidebar-row", "찰쌓기일위대가표"],
|
||||
["1", "ui-sidebar-row", "모래재료비목록표"],
|
||||
]
|
||||
assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
|
||||
assert out["moves"] == [{"tab": "material", "key": "MT9"}]
|
||||
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
|
||||
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
|
||||
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
|
||||
assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == []
|
||||
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
|
||||
|
||||
|
||||
def test_배지_조건_서버_book_stale():
|
||||
keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본)
|
||||
book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False))
|
||||
assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음
|
||||
assert prices.book_stale(None, keys) == "단가 아직 안 굳힘"
|
||||
first, *rest = list(book["rows"])
|
||||
old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []}
|
||||
old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1}
|
||||
old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"}
|
||||
assert prices.book_stale(old, keys) == (
|
||||
"새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜"
|
||||
)
|
||||
router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8")
|
||||
assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router
|
||||
|
||||
|
||||
def test_배지_꼴_경고_색_토큰():
|
||||
css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
block = css[css.index(".b09-options .ui-btn.is-pending::after") :]
|
||||
block = block[: block.index("}")]
|
||||
assert "position: absolute" in block and "border-radius: 50%" in block
|
||||
assert "background: var(--color-warning)" in block
|
||||
@@ -0,0 +1,242 @@
|
||||
"""204-1 B09 설계내역서 — 방향키 · 끌기 레벨 · 이동 · 자손 함께 · 묶음 자동 · 합 전달.
|
||||
|
||||
- 방향키 = 고른 줄(편집 중 아님) → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로) · Alt+←→ 안 받음
|
||||
- 끌기 자리 = `dropAt`(틈 · 바라는 레벨 → 부모 · 앞 줄) ·
|
||||
놓기 = 표 drop(`dropMove` · 메뉴와 같은 지킬 것)
|
||||
- 묶음 자동 = 셈(짝 `Calc_Bill` · 거울) — 자식이 생긴 항목 = 묶음(자손 합 · 위로 전달) ·
|
||||
자식이 빠지면 다시 항목(제 수량 · 단가) · 자식이 다 빠진 새 묶음 = 항목(화면 · 맨 위 공종 빼고)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_184_2_b09_bill_free import ( # noqa: E402
|
||||
BASE,
|
||||
BOOKS,
|
||||
DG,
|
||||
INPUTS,
|
||||
_calc,
|
||||
_freeze,
|
||||
_put,
|
||||
_rows,
|
||||
)
|
||||
from test_184_2_b09_bill_free import _node as _node_calc # noqa: E402
|
||||
from test_184_3_b09_bill_menu import ROWS, _node, _row, _ts # noqa: E402
|
||||
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
|
||||
|
||||
def _order(bill):
|
||||
return [(e["key"], e.get("parent"), e.get("kind")) for e in (bill or {}).get("rows", [])]
|
||||
|
||||
|
||||
# --- 방향키 -----------------------------------------------------------------------------
|
||||
|
||||
KEYS_JS = """
|
||||
const out = {};
|
||||
const press = (pickKey, k, more = {}) => {
|
||||
user = {}; draw(); pick(pickKey); root.focus();
|
||||
return [key(k, more), order()];
|
||||
};
|
||||
out.right = press("earth:5", "ArrowRight");
|
||||
out.left = press("earth:4", "ArrowLeft");
|
||||
out.up = press("earth:5", "ArrowUp");
|
||||
out.down = press("earth:4", "ArrowDown");
|
||||
out.altLeft = press("earth:4", "ArrowLeft", { altKey: true });
|
||||
user = {}; draw(); pick("earth:5");
|
||||
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
||||
out.editing = [key("ArrowRight"), key("ArrowUp"), order()];
|
||||
out.guide = bar.kids.at(-1).textContent;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_방향키_넷_편집_중엔_안_먹음():
|
||||
head = BAR_JS[: BAR_JS.index("const out = {};")]
|
||||
body = head.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + KEYS_JS
|
||||
out = json.loads(_node(_ts(SRC) + body))
|
||||
stopped, order = out["right"] # → = 위 항목 아래로(earth:4 = 저절로 묶음)
|
||||
assert stopped and ["earth:5", "earth:4"] in order
|
||||
stopped, order = out["left"] # ← = 1.1 밖으로(1 아래)
|
||||
assert stopped and ["earth:4", "sec:1"] in order
|
||||
for name in ("up", "down"): # ↑↓ = 같은 부모 안 자리 바꿈
|
||||
stopped, order = out[name]
|
||||
keys = [k for k, _ in order]
|
||||
assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name
|
||||
assert out["altLeft"] == [False, []] # Alt+← = 브라우저 뒤로 — 안 받음
|
||||
assert out["editing"] == [False, False, []] # 글 칸 편집 중 = 글 칸 커서
|
||||
assert "←→" in out["guide"] and "↑↓" in out["guide"] and "끌기" in out["guide"]
|
||||
|
||||
|
||||
# --- 끌기 자리 · 놓기 ------------------------------------------------------------------------
|
||||
|
||||
DROP_JS = """
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const table = buildBill(ctx);
|
||||
const at = (k) => table.rows.findIndex((r) => r.key === k);
|
||||
const order = (b) => (b?.rows ?? []).map((e) => [e.key, e.parent ?? null, e.kind ?? null]);
|
||||
const out = { at: {}, drop: {} };
|
||||
for (const [name, k, gap, want] of __ATS__) out.at[name] = dropAt(table.rows, at(k), gap, want);
|
||||
for (const [name, k, parent, before] of __DROPS__) {
|
||||
user = start;
|
||||
const run = table.drop(at(k), parent, before);
|
||||
if (run) run();
|
||||
out.drop[name] = run ? order(user.bill) : null;
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
#: ROWS + 맨 위 새 공종(hand:n · 자식 hand:k) — 4 자재대 뒤
|
||||
ROWS2 = [*ROWS[:9], _row("hand:n", None, 1, "group", no="5"), _row("hand:k", "hand:n", 2),
|
||||
*ROWS[9:]] # fmt: skip
|
||||
|
||||
|
||||
def _drag(ats=(), drops=(), rows=ROWS2, user=None):
|
||||
body = (
|
||||
DROP_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__USER__", json.dumps(user or {}, ensure_ascii=False))
|
||||
.replace("__ATS__", json.dumps([list(a) for a in ats], ensure_ascii=False))
|
||||
.replace("__DROPS__", json.dumps([list(d) for d in drops], ensure_ascii=False))
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def test_끌기_자리_레벨():
|
||||
# 차례 0 sec:1 · 1 sec:1.2 · 2 earth:4 · 3 earth:5 · 4 hand:g · 5 hand:a · 6 hand:b · 7 sec:4 ·
|
||||
# 8 mat:M1 · 9 hand:n · 10 hand:k · 11 sec:G · 12 mat:M2 · 13 fee
|
||||
out = _drag(
|
||||
ats=[
|
||||
("오른쪽", "earth:5", 3, 4), # 제자리 + 한 칸 오른쪽 = 위 항목 아래로
|
||||
("제자리", "earth:5", 3, 3),
|
||||
("제자리끝", "earth:5", 4, 3),
|
||||
("자손안", "hand:g", 5, 3),
|
||||
("위로", "hand:g", 2, 3),
|
||||
("멀리오른쪽", "hand:g", 2, 9), # 레벨 = 위 줄 + 1 까지
|
||||
("자재아래", "earth:5", 9, 3), # 자재 줄 = 자식 못 둠 → 형제로
|
||||
("맨위안됨", "earth:5", 7, 1), # 항목 = 맨 위(1) 못 감 → 2
|
||||
("맨위공종", "sec:4", 0, 1),
|
||||
]
|
||||
)["at"]
|
||||
assert out["오른쪽"] == {"parent": "earth:4", "before": None, "level": 4}
|
||||
assert out["제자리"] is None and out["제자리끝"] is None and out["자손안"] is None
|
||||
assert out["위로"] == {"parent": "sec:1.2", "before": "earth:4", "level": 3}
|
||||
assert out["멀리오른쪽"] == out["위로"]
|
||||
assert out["자재아래"] == {"parent": "sec:4", "before": None, "level": 2}
|
||||
assert out["맨위안됨"] == {"parent": "sec:1", "before": None, "level": 2}
|
||||
assert out["맨위공종"] == {"parent": None, "before": "sec:1", "level": 1}
|
||||
|
||||
|
||||
def test_놓기_자손_함께_묶음_자동_막음():
|
||||
out = _drag(
|
||||
drops=[
|
||||
("자손", "hand:g", "sec:1.2", "earth:4"),
|
||||
("항목아래", "earth:5", "earth:4", None),
|
||||
("하나빠짐", "hand:a", "sec:1.2", "hand:b"),
|
||||
("맨위남음", "hand:k", "sec:4", None),
|
||||
("관급밖", "mat:M2", "sec:1", None),
|
||||
("수수료", "fee", "sec:1", None),
|
||||
("관급제목", "sec:G", None, "sec:1"),
|
||||
("자재아래", "earth:5", "mat:M1", None),
|
||||
("자기자손", "hand:g", "hand:a", None),
|
||||
]
|
||||
)["drop"]
|
||||
keys = [k for k, *_ in out["자손"]]
|
||||
# 묶음 통째 — 자식 hand:a 는 부모 키 그대로 · 차례 = hand:g 바로 뒤
|
||||
assert keys[keys.index("hand:g") + 1] == "hand:a" and keys.index("hand:g") < keys.index(
|
||||
"earth:4"
|
||||
)
|
||||
assert ["hand:a", "hand:g", None] in out["자손"]
|
||||
assert ["earth:5", "earth:4", None] in out["항목아래"] # 저장 = 부모만(묶음 = 셈이 정함)
|
||||
# 자식이 다 빠진 새 묶음 = 항목(kind 지움) · 맨 위 공종 = 묶음 그대로
|
||||
assert ["hand:g", "sec:1.2", None] in out["하나빠짐"]
|
||||
assert ["hand:n", None, "group"] in out["맨위남음"]
|
||||
for name in ("관급밖", "수수료", "관급제목", "자재아래", "자기자손"):
|
||||
assert out[name] is None, name
|
||||
|
||||
|
||||
def test_깊이_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),
|
||||
_row("hand:y", "sec:1.2", 3),
|
||||
] # fmt: skip
|
||||
out = _drag(drops=[("깊음", "hand:g", "hand:y", None), ("됨", "hand:z", "hand:y", None)],
|
||||
rows=deep)["drop"] # fmt: skip
|
||||
assert out["깊음"] is None
|
||||
assert ["hand:z", "hand:y", None] in out["됨"]
|
||||
|
||||
|
||||
# --- 셈 — 묶음 ↔ 항목 자동 · 합 전달 · 거울 -----------------------------------------------------
|
||||
|
||||
HANDS = [
|
||||
{"key": "hand:a", "parent": "sec:3", "name": "a", "unit": "㎡", "qty": 2, "ref": DG},
|
||||
{"key": "hand:c", "parent": "hand:a", "name": "c", "unit": "㎡", "qty": 1, "ref": DG},
|
||||
]
|
||||
|
||||
|
||||
def _bills():
|
||||
frozen = _freeze(_calc())
|
||||
under = _put(frozen, "earth:1", parent="earth:4") # 1.1 규준틀 → 절토(토사) 아래
|
||||
hands = {**frozen, "rows": frozen["rows"] + HANDS}
|
||||
alone = {**frozen, "rows": frozen["rows"] + HANDS[:1]}
|
||||
mat = _put(frozen, "earth:5", parent="mat:b08:sand")
|
||||
return frozen, under, hands, alone, mat
|
||||
|
||||
|
||||
def test_자식_생긴_항목_묶음_합_전달_빠지면_다시_항목():
|
||||
plain = _calc()
|
||||
frozen, under, hands, alone, mat = _bills()
|
||||
base = _rows(plain)
|
||||
rows = _rows(_calc(under))
|
||||
e1, e4 = rows["earth:1"], rows["earth:4"]
|
||||
assert (e4["kind"], e4["auto"], e4["qty"], e4["ref"], e4["price_sum"]) == (
|
||||
"group", True, None, None, None) # fmt: skip
|
||||
assert (e1["parent"], e1["level"], e4["level"]) == ("earth:4", 4, 3)
|
||||
# 묶음 금액 = 자손 합 → 1.2 · 1 로 전달 · 제 금액은 빠짐
|
||||
assert e4["sum"] == e1["sum"] == base["earth:1"]["sum"]
|
||||
assert rows["sec:1.2"]["sum"] == e1["sum"] and rows["sec:1.1"]["sum"] == 0
|
||||
assert rows["sec:1"]["sum"] == base["sec:1"]["sum"] - base["earth:4"]["sum"]
|
||||
gaps = _calc(under)["bill"]["gaps"]
|
||||
assert not [g for g in gaps if "earth:4" in json.dumps(g, ensure_ascii=False)]
|
||||
# 자식이 빠지면(원래 부모로) = 다시 항목 · 제 수량 · 단가 · 표 그대로
|
||||
assert _calc(_put(under, "earth:1", parent="sec:1.1"))["bill"] == plain["bill"]
|
||||
# 새 항목(수량 2 · 대가3) — 자식이 생기면 묶음(자식 합) · 빠지면 제 값 다시
|
||||
rows = _rows(_calc(hands))
|
||||
assert (rows["hand:a"]["kind"], rows["hand:a"]["sum"], rows["hand:c"]["level"]) == (
|
||||
"group", 1230, 3) # fmt: skip
|
||||
a = _rows(_calc(alone))["hand:a"]
|
||||
assert (a["kind"], a["qty"], a["sum"]) == ("item", 2, 2460)
|
||||
# 저장 다듬기 = 자식 있는 새 항목도 제 수량 · 연결 지킴(묶음은 셈이 정함)
|
||||
kept = {r["key"]: r for r in store.clean_bill(hands)["rows"]}["hand:a"]
|
||||
assert (kept.get("kind"), kept["qty"], kept["ref"]) == (None, 2, DG)
|
||||
# 자재 줄 = 부모 못 됨 → 기본 부모
|
||||
rows = _rows(_calc(mat))
|
||||
assert rows["earth:5"]["parent"] == "sec:1.2" and rows["mat:b08:sand"]["kind"] == "item"
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
calc = _ts(["Calc_Base", "Calc_Bill"])
|
||||
users = [{**BASE, "bill": b} for b in _bills()]
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||||
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
|
||||
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));"
|
||||
)
|
||||
got = json.loads(_node_calc(calc + body))
|
||||
assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""205-1 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄 — 단추 높이 = 상자 안 작은 단추 · 늘 보임.
|
||||
|
||||
- 높이 = `--b05-control-h`(상자 안 단추 · 입력 칸과 같은 값) · 상자 이름표 [삭제]와 같은 높이 식
|
||||
- 줄 = 스크롤 영역 아래 끝에 붙음(sticky · bottom 0) · 단추 · 줄에 hidden · display none · visibility ·
|
||||
호버 조건이 없음
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / "B05_Profile/B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8")
|
||||
FORM = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Form.ts").read_text(encoding="utf-8")
|
||||
SECTION = ".b05-structure-section .b05-route__irregular-actions"
|
||||
|
||||
|
||||
def _block(selector: str) -> str:
|
||||
m = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _decl(block: str, prop: str) -> str:
|
||||
m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block)
|
||||
assert m, prop
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def test_buttons_same_height_as_inner_buttons():
|
||||
outer = _block(SECTION + " > button")
|
||||
inner = re.search(r"\.b05-structure__group > legend > button\s*\{([^}]*)\}", CSS).group(1)
|
||||
assert _decl(outer, "height") == "var(--b05-control-h)"
|
||||
for prop in ("height", "box-sizing", "padding-block", "font-size", "line-height"):
|
||||
assert _decl(outer, prop) == _decl(inner, prop), prop
|
||||
assert re.search(r"--b05-control-h:\s*26px;", CSS)
|
||||
|
||||
|
||||
def test_row_always_visible():
|
||||
row = _block(SECTION)
|
||||
assert _decl(row, "position") == "sticky"
|
||||
assert _decl(row, "bottom") == "0"
|
||||
assert "background" in row
|
||||
for text in (_block(SECTION), _block(SECTION + " > button")):
|
||||
assert not re.search(r"display\s*:\s*none|visibility|opacity|overflow", text)
|
||||
assert not re.search(re.escape(SECTION) + r"[^{]*(:hover|:focus-within|\[hidden\])", CSS)
|
||||
|
||||
|
||||
def test_form_never_hides_buttons():
|
||||
assert "removeButton.hidden" not in FORM and "resetButton.hidden" not in FORM
|
||||
assert "actions.hidden" not in FORM
|
||||
Reference in New Issue
Block a user