- 저장 후 넘어감 → 저장 · 버리고 넘어감 → 폐기 · 다시 불러오기 → 새로고침 · 지우기 → 삭제 등 - test_303 · test_ui_loading 글 맞춤 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
366 lines
18 KiB
Python
366 lines
18 KiB
Python
"""303 B09 행 고름 = 팝업 · 하이퍼링크 스위치 · 좌측 하단 줄.
|
||
|
||
303-1 좌측 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · `DRAFT.actions`(비면 줄 없음) ·
|
||
[?][저장][내역 초기화](도움말 줄) · 스위치 = pref global · 기본 = 팝업
|
||
303-2 표 짜기 = `…_UI_Builders`(화면 · 팝업 · 인쇄 같은 옵션)
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
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, TABS, run # noqa: E402
|
||
|
||
B09 = ROOT / "B09_Estimation"
|
||
DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts"
|
||
BUILDERS = "B09_Estimation/B09_Estimation_UI_Builders.ts"
|
||
|
||
DOCK_JS = (
|
||
"\nconst save = document.createElement('button'); save.textContent = '저장';"
|
||
"\nconst reset = document.createElement('button'); reset.textContent = '내역 초기화';"
|
||
"\nconst shape = (dock) => dock.children.map((r) => ({ sw: r.dataset.b09Switch ?? null,"
|
||
" draft: 'b09DraftActions' in r.dataset, help: 'helpRow' in r.dataset,"
|
||
" text: r.children.map((b) => b.textContent) }));"
|
||
)
|
||
|
||
|
||
def test_하단_줄_차례_초안_단추_없으면_줄_없음():
|
||
out = run(
|
||
[COMMON, TABS, GROUPS, DOCK],
|
||
DOCK_JS + "\nconst a = createDock([save, reset], () => {});"
|
||
"\nDRAFT.actions.push(() => { const b = document.createElement('button');"
|
||
" b.textContent = '인쇄 미리보기'; return b; });"
|
||
"\nconst b = createDock([save, reset], () => {});"
|
||
"\nconsole.log(JSON.stringify({ cls: a.className, a: shape(a), b: shape(b) }));",
|
||
)
|
||
assert out["cls"] == "ui-sidebar-actions b09-dock" # 바닥 고정 = 공용 액션 줄 한 벌
|
||
view = {"sw": "view", "draft": False, "help": False, "text": ["박스보기", "호표보기"]}
|
||
link = {"sw": "link", "draft": False, "help": False, "text": ["하이퍼링크", "팝업"]}
|
||
main = {"sw": None, "draft": False, "help": True, "text": ["저장", "내역 초기화"]}
|
||
assert out["a"] == [view, link, main] # 초안 단추 없음 = 줄 없음
|
||
draft = {"sw": None, "draft": True, "help": False, "text": ["인쇄 미리보기"]}
|
||
assert out["b"] == [view, link, draft, main] # 초안 단추 = [저장] 줄 위
|
||
|
||
|
||
def test_스위치_기본_팝업_누름_pref():
|
||
out = run(
|
||
[COMMON, TABS, GROUPS, DOCK],
|
||
DOCK_JS + "\nconst dock = createDock([save, reset], () => {});"
|
||
"\nconst row = dock.children[1];"
|
||
"\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);"
|
||
"\nconst a = [linkMode(), pressed()];"
|
||
"\nrow.children[0].click();"
|
||
"\nconst b = [linkMode(), pressed(), kept.get('b09-link-mode')];"
|
||
"\nrow.children[1].click();"
|
||
"\nconst c = [linkMode(), pressed(), kept.get('b09-link-mode')];"
|
||
"\nkept.set('b09-link-mode', '\"x\"');"
|
||
"\nconsole.log(JSON.stringify({ a, b, c, odd: linkMode() }));",
|
||
)
|
||
assert out["a"] == ["popup", ["false", "true"]] # 기본 = 팝업
|
||
assert out["b"] == ["link", ["true", "false"], '"link"']
|
||
assert out["c"] == ["popup", ["false", "true"], '"popup"']
|
||
assert out["odd"] == "popup" # 모르는 값 = 팝업
|
||
|
||
|
||
def test_DRAFT_actions_자리():
|
||
tabs = (B09 / "B09_Estimation_UI_Tabs.ts").read_text("utf-8")
|
||
assert "actions: Array<() => HTMLElement>;" in tabs
|
||
assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs
|
||
state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8")
|
||
assert '"b09-link-mode": { bucket: "pref", scope: "global" },' in state
|
||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||
m = re.search(r"\.b09-dock\.ui-sidebar-actions\s*\{([^}]*)\}", css)
|
||
assert m and "flex-direction: column" in m.group(1) # 줄 여럿 세로
|
||
|
||
|
||
# 표 짜기 — 탭 짜기 18 개 대신 가짜(줄 하나) · 옵션 = 표 그대로 넘김
|
||
FAKE_BUILDS = "".join(
|
||
f"\nconst {name} = () => ({{ columns: [], rows: [{{ key: '{name}' }}] }});"
|
||
for name in re.findall(
|
||
r":\s*(build\w+),",
|
||
(ROOT / BUILDERS).read_text("utf-8"),
|
||
)
|
||
)
|
||
|
||
|
||
def test_Builders_화면_같은_옵션():
|
||
out = run(
|
||
[TABS, BUILDERS],
|
||
"\nDRAFT.builders.draft_x = () => '안내';"
|
||
"\nconst spec = { columns: [{ key: 'a', label: 'A' }], rows: [{ key: 'r' }], frozen: 2,"
|
||
" editable: () => true, onEdit: () => {}, rowClass: () => 'x', cellClass: () => 'y',"
|
||
" level: { key: 'a', of: () => 1 }, mergeSame: ['a'], link: () => null, menu: () => [] };"
|
||
"\nconst o = tableOptionsOf(spec, 'bill');"
|
||
"\nconst plain = tableOptionsOf({ columns: [], rows: [], editOnDouble: false });"
|
||
"\nconsole.log(JSON.stringify({ keys: Object.keys(o).sort(), caption: o.caption,"
|
||
" same: ['columns', 'rows', 'editable', 'onEdit', 'rowClass', 'cellClass', 'level', 'mergeSame']"
|
||
".every((k) => o[k] === spec[k]), frozen: o.frozen, total: o.total, empty: o.empty,"
|
||
" dbl: [o.editOnDouble, plain.editOnDouble], key: [o.keyEdit, plain.keyEdit],"
|
||
" plainCaption: plain.caption ?? null, plainFrozen: plain.frozen,"
|
||
" bill: tabTableOf('bill', {}).rows[0].key, draft: tabTableOf('draft_x', {}),"
|
||
" none: tabTableOf('nope', {}) }));",
|
||
FAKE_BUILDS,
|
||
)
|
||
assert out["keys"] == sorted(
|
||
[
|
||
"columns",
|
||
"rows",
|
||
"total",
|
||
"frozen",
|
||
"empty",
|
||
"caption",
|
||
"editable",
|
||
"editOnDouble",
|
||
"keyEdit",
|
||
"onEdit",
|
||
"rowClass",
|
||
"cellClass",
|
||
"level",
|
||
"mergeSame",
|
||
]
|
||
) # 누름 · 메뉴 · 풍선 없음(그리는 쪽 몫)
|
||
assert out["caption"] == "설계내역서" and out["same"] is True
|
||
assert out["frozen"] == 2 and out["total"] is None and out["empty"] == ""
|
||
assert out["dbl"] == [True, False] # 243-1 기본 = 더블클릭
|
||
assert out["key"] == [False, True] # 설계내역서(메뉴) Enter · F2 = 레벨 키 몫
|
||
assert out["plainCaption"] is None and out["plainFrozen"] == 0
|
||
assert out["bill"] == "buildBill" and out["draft"] == "안내" and out["none"] is None
|
||
|
||
|
||
def test_화면이_Builders_옵션을_씀():
|
||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||
assert "...tableOptionsOf(spec, tab)," in shell
|
||
assert "const spec = tabTableOf(tab, ctx);" in shell
|
||
assert "BUILDERS" not in shell
|
||
|
||
|
||
# ── 303-2 팝업 창 ──────────────────────────────────────────────────────────
|
||
POPUP = "B09_Estimation/B09_Estimation_UI_Popup.ts"
|
||
|
||
|
||
def _fn(path: str, name: str) -> str:
|
||
"""파일에서 함수 하나 꺼냄(맨 앞 `export` 뗌) — 이어 붙이면 이름이 부딪히는 파일 대신."""
|
||
src = (ROOT / path).read_text("utf-8")
|
||
m = re.search(rf"export function {name}\(.*?\n}}\n", src, re.S)
|
||
assert m, name
|
||
return m.group(0).replace("export ", "", 1)
|
||
|
||
|
||
POPUP_PRELUDE = (
|
||
FAKE_BUILDS
|
||
+ "\nconst GAP = 6;\n"
|
||
+ _fn("ui_template/ui_template_hover_info.ts", "placeHoverInfo")
|
||
+ _fn("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", "subtreeEnd")
|
||
+ r"""
|
||
E.prototype.remove = function () {
|
||
if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this);
|
||
this.parent = null;
|
||
};
|
||
E.prototype.getBoundingClientRect = function () {
|
||
return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 };
|
||
};
|
||
document.body = new E("body");
|
||
const winFns = { keydown: [], hashchange: [], pointerdown: [], click: [], contextmenu: [] };
|
||
const winKeys = winFns.keydown;
|
||
const hashFns = winFns.hashchange;
|
||
globalThis.window = { innerWidth: 1200, innerHeight: 800,
|
||
addEventListener: (t, f) => winFns[t].push(f) };
|
||
const esc = () => {
|
||
const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } };
|
||
winKeys.forEach((f) => f(ev));
|
||
return ev.stopped;
|
||
};
|
||
// 308 창 밖 누름 — pointerdown → click(또는 우클릭 메뉴) · 먹힘 = stopped
|
||
const press = (target, type = "click") => {
|
||
const ev = (t) => ({ type: t, target, stopped: false, preventDefault() {}, stopPropagation() { this.stopped = true; } });
|
||
const down = ev("pointerdown");
|
||
winFns.pointerdown.forEach((f) => f(down));
|
||
const up = ev(type);
|
||
winFns[type].forEach((f) => f(up));
|
||
return [down.stopped, up.stopped];
|
||
};
|
||
const tables = [];
|
||
const createDataTable = (o) => {
|
||
const root = document.createElement("div");
|
||
o.rows.forEach((r, i) => {
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.i = String(i);
|
||
tr.rect = { left: 40, top: 300 + i * 28, right: 900, bottom: 328 + i * 28 };
|
||
root.append(tr);
|
||
});
|
||
tables.push(o);
|
||
return { root };
|
||
};
|
||
"""
|
||
)
|
||
|
||
SCENE = r"""
|
||
const col = [{ key: "name", label: "명칭", group: ["머리"] }];
|
||
const specs = {
|
||
bill: { columns: col, menu: () => [], level: { key: "name", of: (i) => [1, 2, 2, 1][i] },
|
||
rows: [{ key: "b1", level: 1 }, { key: "b2", level: 2 }, { key: "b3", level: 2 }, { key: "b4", level: 1 }],
|
||
rowClass: (i) => (i === 0 ? "is-title" : null),
|
||
editable: (i, k) => k === "qty",
|
||
link: (i) => ({ tab: "labor", key: "L1" }) },
|
||
labor: { columns: col, rows: [{ key: "L0" }, { key: "L1" }, { key: "L2" }],
|
||
link: (i) => (i === 1 ? { tab: "dg", key: "D1|1" } : null) },
|
||
dg: { columns: col, rows: [{ key: "D1" }, { key: "D1|1" }, { key: "D1|합계" }, { key: "D2" }] },
|
||
};
|
||
const gone = [];
|
||
const base = { specOf: (t) => specs[t] ?? null, go: (l) => gone.push(l) };
|
||
const anchor = document.createElement("tr");
|
||
anchor.rect = { left: 40, top: 72, right: 900, bottom: 100 };
|
||
const boxes = () => document.body.children.map((b) => b.dataset.b09Popup);
|
||
const rows = (k) => tables[k].rows.map((r) => r.key);
|
||
const cls = (k) => tables[k].rows.map((_, i) => tables[k].rowClass(i));
|
||
"""
|
||
|
||
|
||
def _popup(body: str):
|
||
return run([TABS, GROUPS, BUILDERS, POPUP], SCENE + body, POPUP_PRELUDE)
|
||
|
||
|
||
def test_강조_범위_줄_수():
|
||
out = _popup(
|
||
"\nconsole.log(JSON.stringify(["
|
||
"popupRange('bill', specs.bill, 'b1'), popupRange('bill', specs.bill, 'b4'),"
|
||
"popupRange('labor', specs.labor, 'L1'), popupRange('dg', specs.dg, 'D1|1'),"
|
||
"popupRange('labor', specs.labor, 'X')]));"
|
||
)
|
||
# 설계내역서 = 그 줄 + 자손 · 그 밖 = 한 줄 · 묶음 = 호표 구간 · 없음 = null
|
||
assert out == [[0, 2], [3, 3], [1, 1], [0, 2], None]
|
||
|
||
|
||
def test_창_열_제목_강조_줄_자리():
|
||
out = _popup(
|
||
"\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });"
|
||
"\nconst box = document.body.children[0];"
|
||
"\nconst o = tables[0];"
|
||
"\nconsole.log(JSON.stringify({ boxes: boxes(), n: popupCount(), cls: box.className,"
|
||
" head: box.children[0].children.map((e) => e.textContent), rows: rows(0), rowCls: cls(0),"
|
||
" columns: o.columns === specs.bill.columns, caption: o.caption, total: o.total,"
|
||
" editable: o.editable ?? null, lv: [0, 1, 2].map((i) => o.level.of(i)),"
|
||
" table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));"
|
||
)
|
||
assert out["boxes"] == ["bill"] and out["n"] == 1
|
||
assert out["cls"] == "b09-estimation b09-popup ui-popup" # 창 안 = b09 표 꼴(빨강 테두리)
|
||
assert out["head"] == ["설계내역서", "탭 이동", "×"]
|
||
assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손
|
||
assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"]
|
||
assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로
|
||
assert out["total"] is None and out["editable"] is None # 합계 · 고칠 칸 없음
|
||
assert out["lv"] == [1, 2, 2] and out["table"] == "bill"
|
||
assert out["left"] == "40px" and out["top"] == "106px" # 누른 줄 아래(간격 6)
|
||
|
||
|
||
def test_이어진_창_겹침_Esc_차례():
|
||
out = _popup(
|
||
"\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });"
|
||
"\ntables[0].onCellClick(1, 'qty');" # 고칠 칸 = 안 엶
|
||
"\nconst a = boxes();"
|
||
"\ntables[0].onCellClick(1, 'name');" # 창 안 링크 줄 = 이어진 창
|
||
"\nconst second = document.body.children[1];"
|
||
"\ntables[1].onCellClick(0, 'name');" # 창 안 줄 0 = 원래 줄 1(L1) → dg
|
||
"\nconst b = boxes();"
|
||
"\nconst dg = rows(2);"
|
||
"\nconst e1 = esc(); const c = boxes();"
|
||
"\nconst e2 = esc();"
|
||
"\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2],"
|
||
" d: boxes(), gone }));"
|
||
)
|
||
assert out["a"] == ["bill"]
|
||
assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위)
|
||
assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간
|
||
assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁
|
||
assert out["c"] == [] # 308 Esc = 모두
|
||
assert out["e"] == [True, False] # 창 없으면 Esc 안 가로챔
|
||
assert out["d"] == [] and out["gone"] == []
|
||
|
||
|
||
def test_닫기_탭으로_새_누름_주소():
|
||
out = _popup(
|
||
"\nconst open = (key = 'b1') => openRowPopup({ ...base, link: { tab: 'bill', key }, anchor });"
|
||
"\nopen(); tables[0].onCellClick(0, 'name');"
|
||
"\nconst head = (k) => document.body.children[k].children[0].children;"
|
||
"\nhead(0)[2].click();" # 아래 창 × = 그 창 + 위 창
|
||
"\nconst a = boxes();"
|
||
"\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로
|
||
"\nconst b = [boxes(), rows(4)];"
|
||
"\nhead(0)[1].click();" # [탭 이동] = 옮기고 모두 닫음
|
||
"\nconst c = [boxes(), [...gone]];"
|
||
"\nopen(); hashFns.forEach((f) => f());"
|
||
"\nconst d = boxes();"
|
||
"\nopenRowPopup({ ...base, link: { tab: 'labor', key: 'X' }, anchor });" # 보일 줄 없음 = 옮김
|
||
"\nconsole.log(JSON.stringify({ a, b, c, d, e: [boxes(), gone.length] }));"
|
||
)
|
||
assert out["a"] == []
|
||
assert out["b"] == [["bill"], ["b4"]]
|
||
assert out["c"] == [[], [{"tab": "bill", "key": "b4"}]]
|
||
assert out["d"] == [] # 주소 바뀜 = 모두 닫음
|
||
assert out["e"] == [[], 2]
|
||
|
||
|
||
def test_308_맨_위_창_밖_누름_모두_닫힘_누름_먹음():
|
||
out = _popup(
|
||
"\nconst outside = document.createElement('td'); document.body.append(outside);"
|
||
"\nconst open3 = () => { openRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });"
|
||
" tables[tables.length - 1].onCellClick(1, 'name');"
|
||
" tables[tables.length - 1].onCellClick(0, 'name'); };"
|
||
"\nconst pops = () => boxes().filter(Boolean);"
|
||
"\nconst top = () => document.body.children[document.body.children.length - 1];"
|
||
"\nconst under = () => document.body.children.filter((b) => b.dataset.b09Popup)[1];"
|
||
"\nopen3(); const inTop = press(top().children[1]); const a = pops();" # 맨 위 창 안 = 그대로
|
||
"\nconst right0 = press(top().children[0], 'contextmenu'); const a2 = pops();"
|
||
"\nconst low = press(under().children[1]); const b = pops();" # 아래 창 누름
|
||
"\nopen3(); const blank = press(outside); const c = pops();" # 쪽 빈 곳 · 톡
|
||
"\nopen3(); const right = press(outside, 'contextmenu'); const d = pops();" # 우클릭
|
||
"\nconst none = press(outside);" # 창 없음 = 안 가로챔
|
||
"\nconsole.log(JSON.stringify({ inTop, a, right0, a2, low, b, blank, c, right, d, none, gone }));"
|
||
)
|
||
full = ["bill", "labor", "dg"]
|
||
assert out["inTop"] == [False, False] and out["a"] == full # 맨 위 창 안 = 그대로
|
||
assert out["right0"] == [False, False] and out["a2"] == full
|
||
assert out["low"] == [True, True] and out["b"] == [] # 모두 닫힘 · 그 누름 먹음
|
||
assert out["blank"] == [True, True] and out["c"] == []
|
||
assert out["right"] == [True, True] and out["d"] == []
|
||
assert out["none"] == [False, False] and out["gone"] == []
|
||
|
||
|
||
def test_창_자리_화면_안():
|
||
out = _popup(
|
||
"\nconst v = { width: 1200, height: 800 };"
|
||
"\nconsole.log(JSON.stringify(["
|
||
"popupPlace({ left: 40, top: 700, right: 900, bottom: 728 }, { width: 400, height: 300 }, v),"
|
||
"popupPlace({ left: 1000, top: 100, right: 1190, bottom: 128 }, { width: 400, height: 300 }, v),"
|
||
"popupPlace({ left: 40, top: 400, right: 900, bottom: 428 }, { width: 400, height: 700 }, v)]));"
|
||
)
|
||
assert out[0] == {"x": 40, "y": 394} # 아래 모자람 = 위
|
||
assert out[1] == {"x": 794, "y": 134} # 오른쪽 화면 안
|
||
assert out[2] == {"x": 40, "y": 92} # 위아래 다 모자람 = 화면 안으로 밀어 넣음
|
||
|
||
|
||
def test_화면_잇기_모드별_누름_길():
|
||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||
assert (
|
||
'if (linkMode() === "link") return show(link.tab, link.key);' in shell
|
||
) # 하이퍼링크 그대로
|
||
assert "openRowPopup({" in shell and "go: (to) => show(to.tab, to.key)," in shell
|
||
assert "closePopups(); // 303-2 탭 바꿈" in shell # 탭 바꿈 = 모두 닫음
|
||
assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell
|
||
assert "if (spec.link && !spec.editable?.(i, key))" in shell # 고칠 칸 = 안 옮김
|
||
assert (
|
||
"createCheckPanel(refresh, (link) => show(link.tab, link.key));" in shell
|
||
) # 연결 없음 = 옮김
|
||
groups = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8")
|
||
assert 'cards.querySelector(`[data-at="${i}"]`)' in groups # 박스 카드 누름도 같은 길
|
||
popup = (ROOT / POPUP).read_text("utf-8")
|
||
assert "...tableOptionsOf(spec, tab)," in popup # 화면과 같은 표 짜기
|
||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||
m = re.search(r"\.b09-estimation\.b09-popup\s*\{([^}]*)\}", css)
|
||
assert m and "max-width: 90vw" in m.group(1) and "position: fixed" in m.group(1)
|