diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 5662184dd..33767ed58 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -351,9 +351,11 @@ export class TableEntity implements Entity { data.cells, data.style ); - table.id = jsonEntity.id; - table.lineColor = jsonEntity.lineColor; - table.lineWidth = jsonEntity.lineWidth; + // 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의 + // 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다. + table.id = jsonEntity.id || table.id; + table.lineColor = jsonEntity.lineColor || table.lineColor; + table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth; table.lineDash = jsonEntity.lineDash; return table; } diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts index f11cab527..741cf8246 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -77,6 +77,28 @@ describe('표 놓기(PLACETABLE)', () => { }); }); +describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => { + it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => { + const json = await table('T8').toJson(); + if (!json) throw new Error('표 JSON 없음'); + const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json; + await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' }, + vi.fn() + ); + const pending = getPendingTable(); + expect(pending?.lineColor).toBe('#fff'); + expect(pending?.lineWidth).toBe(1); + const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id); + placed.lineColor = pending?.lineColor ?? ''; + const saved = await placed.toJson(); + expect(saved?.lineColor).toBe('#fff'); + const back = await TableEntity.fromJson(bare as typeof json); + expect(back.id).toBeTruthy(); + expect(back.lineColor).toBe('#fff'); + }); +}); + describe('표 칸 갈기(update-table)', () => { it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { const linked = table('T1'); diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index a5ae96b66..df2b05f5c 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -325,11 +325,8 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An mats = [] for one in doc.get("자재") or []: code = str(one.get("코드") or "") - try: - price, _ = mm.element(snapshot, code, {}, one.get("단위") or None) - except mf.FormulaError: - price = None - mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price}) + got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3) + mats.append({"코드": code, "알약": labor_doc.material_key(code), **got}) settled = labor_doc.settle( to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) ) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index eaf3979d3..1dd338ff5 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -5,7 +5,8 @@ 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 -반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · +기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. """ @@ -247,6 +248,24 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) +def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: + """자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element` + (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만.""" + row = master.get(code) or {} + prices = {slot: row.get(slot) for slot in mm.SLOTS} + try: + price, source = mm.element(master, code, {}, unit or None) + except mf.FormulaError as e: + return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)} + slot = str(source).split(".")[-1] if source else None + return { + "기준단가": price, + "가격": prices, + "기준칸": slot if slot in mm.SLOTS else None, + "까닭": None if price is not None else "단가 없음", + } + + def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" @@ -277,17 +296,14 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) - mats = [] - for one in doc.get("자재") or []: - try: - price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) - except mf.FormulaError as e: - price, why = None, str(e) - else: - why = None if price is not None else "단가 없음" - mats.append( - {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} - ) + mats = [ + { + "코드": one["코드"], + "알약": material_key(one["코드"]), + **material_price(whole, one["코드"], one.get("단위")), + } + for one in doc.get("자재") or [] + ] have = _area_of(doc.get("열")) if given is None else given return settle(_json({"줄": lines, "자재": mats}), doc, have) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 3732937a4..4f0641332 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -58,6 +58,13 @@ font-size: var(--text-caption); } +/* 자재 가격 다섯 — 기준단가가 된 칸(43-3) */ +.m02-labrow__price.is-base-price { + color: var(--color-accent); + font-weight: 700; + box-shadow: inset 0 -2px 0 var(--color-accent); +} + .m02-labrow__applied { display: block; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dcff3da3c..dd9da6fe9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -28,6 +28,10 @@ export interface CalcMaterial { 코드: string; 알약?: string | null; 기준단가: number | null; + /** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */ + 가격?: Record; + /** 기준단가가 된 열 이름 — 없으면 null */ + 기준칸?: string | null; 수량?: number | null; 수량출처?: QtySource; 반영?: number | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index a0c5500a1..0b9f927fe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -3,7 +3,8 @@ * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. - * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). + * 금액은 calc 답(없으면 「…」). * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). * ========================================================================== */ @@ -280,7 +281,12 @@ export interface MatTableData { cells?: ReadonlyMap; } -/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ +export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; + +/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 + * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · + * 보기 갈래는 `on` 없이 */ export function matTable(data: MatTableData, on?: Omit): HTMLElement { const wait = !data.calc && !data.fail; const remove = on?.remove; @@ -288,7 +294,20 @@ export function matTable(data: MatTableData, on?: Omit): HT wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); const trs = data.mats.map((mat, i) => { const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); - const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 + const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!line?.기준칸 && line.기준칸 === slot; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: show(line?.가격?.[slot]), + attrs: { + "data-label": slot, + "data-price-slot": slot, + ...(base ? { title: tc("Labor_BasePrice") } : {}), + }, + }); + }; const tr = el("tr", { className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-mat": mat.코드 }, @@ -304,29 +323,14 @@ export function matTable(data: MatTableData, on?: Omit): HT ], }), el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }), + qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "", attrs: { "data-label": tc("Labor_Unit") }, }), - el("td", { - className: "m02-labrow__num", - text: qty, - attrs: { - title: qtyText(line, mat.단위 ?? "", data.cells), - "data-label": tc("Labor_Qty"), - }, - }), - el("td", { - className: "m02-labrow__num", - text: show(line?.기준단가), - attrs: { "data-label": tc("Labor_BasePrice") }, - }), - el("td", { - className: "m02-labrow__num", - text: show(line?.반영), - attrs: { "data-label": tc("Labor_AppliedSum") }, - }), + ...PRICE_SLOTS.map(priceCell), + moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), ...(remove ? [ el("td", { @@ -353,10 +357,10 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), + tc("Labor_QtyCol"), tc("Labor_Unit"), - tc("Labor_Qty"), - tc("Labor_BasePrice"), - tc("Labor_AppliedSum"), + ...PRICE_SLOTS, + `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; return data.mats.length diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 13630fac5..77d6b3146 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -130,8 +130,8 @@ export function createMain( const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); - // 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) - const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" }); + // 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님) + const back = createButton({ label: L("M02_Back") }); const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index ca3f2b4cb..90c0f831c 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -181,6 +181,9 @@ def test_계산_반영_합은_손셈_원본과_같음(world): (mat,) = got["자재"] assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + assert ( + mat["가격"][mat["기준칸"]] == mat["기준단가"] + ) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3) total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] assert got["반영합"]["계"] == pytest.approx(total) # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index 9f2cd52b1..adee730ee 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -98,6 +98,9 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): assert (thousand["수량"], thousand["수량출처"]) == (1, "손") mat = got["자재"][0] assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + # 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름 + assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] + assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000 nothing = labor.calc(doc, {})["줄"][0] assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index be79312be..d73ce71e0 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -238,3 +238,10 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body assert "sheet?.selectColumn(column)" in body + + +def test_상세_머리_돌아가기는_일반_단추(): + """43-5 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] = 공용 기본 단추(filled · 같은 높이 · + 알약 · ghost · sm 아님) · 대비는 ui_checks/43-5 시나리오(어두움 8.01).""" + main = _text(MAIN) + assert 'const back = createButton({ label: L("M02_Back") });' in main diff --git a/resources/tester/ui_checks/43-3_자재가격.js b/resources/tester/ui_checks/43-3_자재가격.js new file mode 100644 index 000000000..694b78810 --- /dev/null +++ b/resources/tester/ui_checks/43-3_자재가격.js @@ -0,0 +1,70 @@ +// 43-3 자재 줄 2행 · 가격 다섯 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌). +// 시작 화면 = M02 마스터 템플릿(#/m02-master-template · 시스템 관리자 · 마스터 층). +// 하는 일 = 좌측 일위대가 SU-000001 상세 → [자재 추가] → MT004177(시멘트, 전국) 고름 → 줄 수치 읽음 → [빼기]로 되돌림(저장 안 함). +// 기대 = heads 에 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · prices = 6,545 · 6,727 · 6,090 · 6,181 · — · +// base = 물가정보(굵게 · 강조) · money.base = 6,090 · qty.base = 「1 포」 · after = 0(빼기 뒤 줄 없음). +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const got = fn(); + if (got) return got; + await wait(150); + } + return null; + }; + const CODE = "MT004177"; + const group = [...document.querySelectorAll(".m02-side__group")].find((s) => + s.querySelector(".ui-collapsible__title")?.textContent.trim().startsWith("일위대가"), + ); + if (!group) return JSON.stringify({ error: "좌측 일위대가 없음" }); + if (group.classList.contains("is-collapsed")) + group.querySelector(".ui-collapsible__title").click(); + const su = [...group.querySelectorAll("button")].find((b) => + b.textContent.startsWith("SU-000001"), + ); + su?.click(); + const add = await until(() => + [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "자재 추가"), + ); + if (!add) return JSON.stringify({ error: "자재 추가 단추 없음" }); + add.click(); + const box = await until(() => document.querySelector(".m02-pick input")); + let item = null; + for (let tries = 0; tries < 3 && !item; tries += 1) { + // 서버가 막 다시 떴으면 첫 찾기가 늦음 — 다시 침(찾기 칸 글 = 코드) + box.value = tries ? `${CODE} ` : CODE; + box.dispatchEvent(new Event("input", { bubbles: true })); + item = await until(() => { + const items = [...document.querySelectorAll(".m02-pick__item")]; + return items.length === 1 && items[0].dataset.material === CODE ? items[0] : null; + }, 4000); + } + if (!item) return JSON.stringify({ error: "고르기 창에 코드 줄 없음" }); + item.click(); + const row = await until(() => { + const tr = document.querySelector(`tr[data-labor-mat="${CODE}"]`); + return tr && !tr.textContent.includes("…") ? tr : null; + }, 8000); + if (!row) return JSON.stringify({ error: "자재 줄이 안 섬(계산 대기)" }); + const table = row.closest("table"); + const out = { + heads: [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim()), + prices: [...row.querySelectorAll("[data-price-slot]")].map((td) => td.textContent.trim()), + base: [...row.querySelectorAll(".is-base-price")].map((td) => td.dataset.priceSlot), + baseWeight: getComputedStyle(row.querySelector(".is-base-price") ?? row).fontWeight, + qty: { + base: row.querySelector(".m02-labrow__qty .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__qty .m02-labrow__applied")?.textContent.trim(), + }, + money: { + base: row.querySelector(".m02-labrow__money .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__money .m02-labrow__applied")?.textContent.trim(), + }, + }; + // 되돌림 — 그 줄 [빼기](저장 안 함) + [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "빼기")?.click(); + await wait(400); + out.after = document.querySelectorAll(`tr[data-labor-mat="${CODE}"]`).length; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/43-5_돌아가기_대비.js b/resources/tester/ui_checks/43-5_돌아가기_대비.js new file mode 100644 index 000000000..7207f3c96 --- /dev/null +++ b/resources/tester/ui_checks/43-5_돌아가기_대비.js @@ -0,0 +1,96 @@ +/* 43-5 · E1 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] 꼴 · 머리 단추 글자 대비(밝음 · 어두움) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - 세 상세 모두 back.cls = "ui-btn ui-btn--filled"(ghost · sm 없음) · back.h = 머리 다른 단추 높이(±1px) + * - 밝음 · 어두움(앱 ☾ 단추) 모두 머리 보이는 단추 대비 ≥ 4.5 + * - 끝나면 처음 테마로 되돌림(theme.end = theme.start) + * ⚠ 단추 color 전환(120ms)이 ORCA 탭에선 0ms 에 멈춰 옛 색이 읽힘(1.11 착오 · E1) — 재는 동안 전환을 끔 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number); + const lum = ([r, g, b]) => { + const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + /** 실제로 보이는 바탕 — 투명이면 조상으로 */ + const bgOf = (el) => { + for (let e = el; e; e = e.parentElement) { + const c = rgb(getComputedStyle(e).backgroundColor); + if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3); + } + return [255, 255, 255]; + }; + const ratio = (el) => { + const a = lum(rgb(getComputedStyle(el).color).slice(0, 3)); + const b = lum(bgOf(el)); + return +((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2); + }; + const theme = () => document.documentElement.getAttribute("data-theme") || "auto"; + const moon = () => + [...document.querySelectorAll("button")].find((b) => /^[☾☀]$/.test(b.textContent.trim())); + const headButtons = () => + [...document.querySelectorAll(".m02-main__head button")].filter( + (b) => !b.hidden && b.offsetParent, + ); + const openRow = async (sectionTitle, prefix) => { + const sec = [...document.querySelectorAll(".m02-side__summary > section")].find( + (s) => s.querySelector("h3").textContent.trim() === sectionTitle, + ); + sec.classList.remove("is-collapsed"); + [...sec.querySelectorAll("button")].find((b) => b.textContent.startsWith(prefix)).click(); + await w(5000); + }; + const measure = () => { + const back = [...document.querySelectorAll(".m02-main__head button")].find( + (b) => b.textContent.trim() === "돌아가기", + ); + const others = headButtons().filter((b) => b !== back); + return { + title: document.querySelector(".m02-main__title")?.textContent, + back: back && { + cls: back.className, + h: Math.round(back.getBoundingClientRect().height), + radius: getComputedStyle(back).borderRadius, + }, + otherH: others.map((b) => Math.round(b.getBoundingClientRect().height)), + contrast: Object.fromEntries( + headButtons().map((b) => [b.textContent.trim() || b.title, ratio(b)]), + ), + }; + }; + const still = document.createElement("style"); + still.textContent = "*,*::before,*::after{transition:none!important}"; + document.head.append(still); + const start = theme(); + const out = { theme: { start }, pages: {} }; + for (const [name, sec, prefix] of [ + ["도면", "구조물 도면", "SD-000001"], + ["일위대가", "일위대가", "SU-000001"], + ["면적산출", "면적산출", "SA-000001"], + ]) { + await openRow(sec, prefix); + const light = measure(); + moon()?.click(); + await w(400); + const dark = { theme: theme(), ...measure() }; + moon()?.click(); + await w(400); + out.pages[name] = { light, dark }; + } + if (theme() !== start) moon()?.click(); + out.theme.end = theme(); + still.remove(); + const all = Object.values(out.pages).flatMap((p) => [ + ...Object.values(p.light.contrast), + ...Object.values(p.dark.contrast), + ]); + out.minContrast = Math.min(...all); + out.backOk = Object.values(out.pages).every( + (p) => + p.light.back?.cls === "ui-btn ui-btn--filled" && + p.light.otherH.every((h) => Math.abs(h - p.light.back.h) <= 1), + ); + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/43-6_도면표_선색.js b/resources/tester/ui_checks/43-6_도면표_선색.js new file mode 100644 index 000000000..15e939a3f --- /dev/null +++ b/resources/tester/ui_checks/43-6_도면표_선색.js @@ -0,0 +1,67 @@ +/* 43-6 도면 표 선 색(흠 E2) — orca eval 한 번에 도는 시나리오(읽기만 · 저장 안 함). + * 시작 화면: M02 · 시스템 관리자 · 구조물 도면 상세(마스터 item1 · CAD 열림 · 보기 전용 아님). + * 하는 일: 면적산출 범위 → 표 변환(POST …/area/item1/table · 답에 lineColor 없음) → CAD 에 place-table → + * 커서 옮김 → 누름(놓기) → save-request 로 CAD 도면 JSON 받음 → Ctrl+Z 로 되돌림. + * 기대: 변환 답 lineColor 없음(answerHasColor false) · 놓인 표 1 · 저장 JSON 표 lineColor = "#fff" · + * lineWidth 1 · id 있음 · 되돌린 뒤 표 수 = 시작 수. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const f = document.querySelector("iframe"); + const w = f.contentWindow; + const cad = w.__aisloCad; + const before = cad.entities().filter((e) => e.type === "Table").length; + const area = await ( + await fetch("/api/m02/layers/system/templates/area/item1", { credentials: "include" }) + ).json(); + const sheet = area.문서.시트[0].id; + const got = await ( + await fetch("/api/m02/layers/system/area/item1/table", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 시트: sheet, 범위: "A1:B2", 글자크기: 2.5 }), + }) + ).json(); + const entity = got.표; + w.postMessage({ type: "aislo:b08:place-table", entity, link: "T43" }, location.origin); + await W(600); + const c = f.contentDocument.querySelector("canvas"); + const r = c.getBoundingClientRect(); + const at = { bubbles: true, button: 0, clientX: r.left + 320, clientY: r.top + 260 }; + c.dispatchEvent(new MouseEvent("mousemove", at)); + await W(window.__hold ?? 300); // 사진 찍을 때는 __hold 를 늘림 + c.dispatchEvent(new MouseEvent("mousedown", at)); + c.dispatchEvent(new MouseEvent("mouseup", at)); + await W(500); + const placed = cad.entities().filter((e) => e.type === "Table").length - before; + let saved = null; + const take = (e) => { + if (e.data && e.data.type === "aislo:b08:save-response") saved = e.data.drawing; + }; + window.addEventListener("message", take); + w.postMessage({ type: "aislo:b08:save-request" }, location.origin); + await W(1000); + window.removeEventListener("message", take); + const table = (saved?.entities ?? []).find( + (e) => e.type === "Table" && e.shapeData?.style?.연결 === "T43", + ); + f.contentDocument.body.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + code: "KeyZ", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + await W(400); + return JSON.stringify({ + answerHasColor: "lineColor" in entity, + placed, + lineColor: table?.lineColor ?? null, + lineWidth: table?.lineWidth ?? null, + hasId: !!table?.id, + afterUndo: cad.entities().filter((e) => e.type === "Table").length === before, + }); +})();