Files
Aislo/resources/tester/test_303_b09_row_popup.py
T
eomsangdonandClaude Sonnet 5.5 2a378eb5c8 fix(버튼): 357-3 서술형 버튼 이름 → 명사형 · 「단추」 → 「버튼」 (A06 · A07 · B01 · B03 · B09 · B11 · M01 · 열린 물음 · 스프레드시트 · 도움말)
- 저장 후 넘어감 → 저장 · 버리고 넘어감 → 폐기 · 다시 불러오기 → 새로고침 · 지우기 → 삭제 등
- test_303 · test_ui_loading 글 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 20:38:55 +09:00

366 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)