From e313a3f6045a9d54276cb5987a8034711f7af9b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:32:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8B=B4=EC=9D=8C=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=BB=A4=EB=B0=8B=EA=B3=BC=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 023a682e3..c8dbf6477 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -72,3 +72,19 @@ 추가로 원문에서 표는 스크롤로 매우 낮은 공간만 보이고 스크롤 표현되어서 보기가 힘듬. 그냥 표가 길더라도 보여줄것. 어차피 상세페이지와 원문의 우측 패널의 영역은 별도의 스크롤 영역이라 무관함. 원문은 M01과 M02에 표현되고 있는데 일원화 할것. M02가 더 가독성이 좋음. + +## 일위대가 조합 · 면적산출 알약 (10-5 · 원문 그대로) + +다시 돌아가서 M02 페이지에 +1-1. 구조물 조합의 면적산출에서 도면으로 보내진 셀의 영역을 표현이 되게하기 +1-2. 구조물 조합의 면적산출에서 알약을 선택을 했을때 셀이 반짝임은 있지만 반대로 셀을 선택했을때는 잘 안보임. +1-3. 구조물 조합의 면적산출에서 일위대가의 로직을 삭제했을때 기 지정된 알약이 잔존하고 있음. 삭제가 힘들다면 느낌표라도 보여지게 하고 삭제가능한지 검토 +2. 원문 표기의 '산림품셈 3-4-2 1' 항목 확인 +3. 구조물 조합의 일위대가 상세 화면에서 단가산출 로직에 들어가는 로직항목들은 단가산출 로직 뿐만아니라 단일 단가산출 로직을 일위대가로 승격시킨 선택지와 다른 일위대가로 만들어진 조합도 선택이 되어야함. +4. 방법 : 구조물 조합 컨테이너 하단에 일위대가 조합 컨테이너를 추가하고 여기에 마스터, 회사, 개인의 권한을 구현, 이때는 두가지의 선택지가 있음. 하나는 일위대가로 승격시킨 단가산출로직의 리스트와 일위대가에 사용하기 전 사전 조합으로 만들어진 조합으로 구분함. +5. 단가산출 승격은 단가산출 로직의 항목들을 검색하여 선택하면 일위대가 형태로 저장이 됨. (도면 X, 면적산출 X) +6. 일위대가 사전 조합은 단가산출 승격 + 다른 일위대가의 조합으로 그룹(도면 X, 면적산출 X) +7. 일위대가 조합은 구조물 조합의 일위대가 상세화면에서 선택할 수 있어야함. +8. 구조물 조합의 일위대가 상세화면에서의 로직테이블은 일위대가 목록표임. 여기서 행을 눌렀을때 하단에 상세 내역이 보였으면 함. 상세내역은 M01페이지의 단가산출 로직을 보면 시험계산 컨테이너 내부에 기준단위에 대한 테이블 리스트가 있음. 이걸 B02 구조물 조합의 일위대가 조합에 맞게 넣어주면 됨. 행을 선택했을때만 보여주면 되고 다른 행을 선택하면 닫히면 됨. +9. 구조물 조합의 일위대가 상세화면에 단가산출 로직 테이블의 이름 좌측 열을 추가하고 구분을 추가함. 구분은 출처로 단가산출인지, 일위대가 조합인지, 단가산출을 승격한 정보인지 표기 항목은 4음절로 표기, +일단 상세히 반영 가능성에 대해 상세히 검토후 계획서에 반영 From 28f399bc2d081515623273fdf8f1b19c45730151 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:40:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=B3=B4=EB=82=B8=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=91=9C=EC=8B=9C=20=E2=80=94=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EB=82=B8=20=EB=B2=94=EC=9C=84=20=EC=A0=90?= =?UTF-8?q?=EC=84=A0=20+=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=C2=B7=20=EB=82=A8=EC=9D=80=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=E2=9A=A0=20+=20=C3=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet_links.css | 69 +++++++++++++++++ A00_Common/spreadsheet/spreadsheet_links.ts | 76 +++++++++++++++++-- .../M02_MasterTemplete_AreaToDrawing.ts | 26 ++++++- .../M02_MasterTemplete_Structure.ts | 4 +- .../M02_MasterTemplete_UI_Main.ts | 7 +- resources/tester/spreadsheet/harness_links.ts | 18 ++++- resources/tester/test_m02_area_marks.py | 35 +++++++++ .../tester/ui_checks/50-1_보낸범위_읽기.js | 42 ++++++++++ .../tester/ui_checks/50-1_알약_강조_표시.js | 52 +++++++++++++ 9 files changed, 316 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_m02_area_marks.py create mode 100644 resources/tester/ui_checks/50-1_보낸범위_읽기.js create mode 100644 resources/tester/ui_checks/50-1_알약_강조_표시.js diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 01a82b2c9..30694cd8e 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -195,3 +195,72 @@ transform: translate(-100%, 0); pointer-events: none; } + +/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */ +.ss-link.is-here { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + font-weight: 600; +} + +.ss-link.is-pop { + animation: ss-link-pop 1.2s ease-out 1 both; +} + +@keyframes ss-link-pop { + 0%, + 40%, + 80% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent); + } + 20%, + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */ +.ss-link.is-orphan { + border: 1px dashed var(--color-danger); + color: var(--color-danger); +} + +.ss-link__x { + margin-left: 2px; + padding: 0 4px; + border-radius: 50%; + font-weight: 700; + cursor: pointer; +} + +.ss-link__x:hover { + background: color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +.ss-link-cell.is-orphan { + outline: 1px dashed var(--color-danger); + outline-offset: -2px; +} + +/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +.ss-link-range { + position: absolute; + z-index: 3; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; +} + +.ss-link-range__tag { + position: absolute; + left: -2px; + bottom: 100%; + padding: 0 4px; + border-radius: 3px 3px 0 0; + background: var(--color-accent); + color: var(--color-canvas); + font-size: 10px; + line-height: 14px; + white-space: nowrap; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 978a9c8b4..7525085a4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -15,7 +15,7 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; -import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; +import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; @@ -53,6 +53,11 @@ export interface LinkOptions { onItemsChange?: (항목: LinkItem[]) => void; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; + /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 + * 목록에 없는 연결 알약에 ⚠ + [×] */ + 확정?: boolean; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ + 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; } export interface Links extends PartHandle { @@ -74,6 +79,8 @@ const TEXT = { fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", dup: "이미 목록에 있는 자재", + orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", + orphanX: "연결 풀기", }; type RowCol = Extract; @@ -146,6 +153,8 @@ export function mountLinks( let dragging: LinkItem | null = null; const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; + /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ + let lastAt = ""; /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -204,6 +213,8 @@ export function mountLinks( item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined, hue: number | undefined, + orphan: boolean, + here: string, ) { const where = hit ? hit.sheet.id === ctx.selection.시트 @@ -211,15 +222,30 @@ export function mountLinks( : `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}` : ""; const b = el("button", { - className: hit ? "ss-link is-linked" : "ss-link", - attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 }, + className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${ + here ? ` is-here${here === "new" ? " is-pop" : ""}` : "" + }`, + attrs: { + type: "button", + title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free, + "data-key": item.키, + }, children: [ - el("span", { text: item.이름 }), + el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }), ...(item.단위 ? [el("small", { text: item.단위 })] : []), ...(where ? [el("code", { text: where })] : []), ], }); if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + if (orphan && !ctx.readOnly) { + const x = el("span", { + className: "ss-link__x", + text: "×", + attrs: { role: "button", title: TEXT.orphanX }, + }); + x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back())); + b.append(x); + } b.draggable = !ctx.readOnly; b.addEventListener("dragstart", (e) => { dragging = item; @@ -233,13 +259,13 @@ export function mountLinks( // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); - const unlink = (): void => { + function unlink(): void { if (hit) put( hit.sheet, (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), ); - }; + } b.addEventListener("contextmenu", (e) => { e.preventDefault(); const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); @@ -307,7 +333,17 @@ export function mountLinks( : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); const hues = linkHues(map.keys()); - const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); + const listed = new Set(items.map((i) => i.키)); + const sure = opts.모드 === "입력" && opts.확정 === true; + // 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new") + const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`; + const fresh = at !== lastAt; + lastAt = at; + const pills = shown.map((item) => { + const hit = map.get(item.키); + const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : ""; + return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here); + }); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -318,6 +354,8 @@ export function mountLinks( pills.push(more); } bar.replaceChildren(...pills); + if (fresh) + bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ @@ -325,6 +363,8 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); + const listed = new Set(items.map((i) => i.키)); + const sure = opts?.모드 === "입력" && opts.확정 === true; // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); @@ -340,6 +380,7 @@ export function mountLinks( dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; const marks = [tint, dot]; + if (sure && !listed.has(first.키)) tint.classList.add("is-orphan"); if (list.length > 1) { const count = el("div", { className: "ss-link-count", text: String(list.length) }); count.style.left = `${box.x + box.w}px`; @@ -350,6 +391,22 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } + // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) + for (const mark of opts?.표시범위 ?? []) { + const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; + if (!g) continue; + const { layer, box } = slot(ctx, g.r0, g.c0); + const end = slot(ctx, g.r1, g.c1).box; + if (!box.w || !box.h) continue; + const frame = el("div", { + className: "ss-link-range", + attrs: { "data-range": mark.범위 }, + children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + }); + frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`; + layer.append(frame); + dots.push(frame); + } } /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ @@ -360,6 +417,11 @@ export function mountLinks( const names = a1 ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) : []; + if (hit && hit.r >= 0 && hit.c >= 0) + for (const m of opts?.표시범위 ?? []) { + const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null; + if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름); + } if (names.length) ctx.grid.root.title = names.join(" · "); else if (tipped) ctx.grid.root.removeAttribute("title"); tipped = names.length > 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index f686ed1e3..da16b9918 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -19,7 +19,13 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; +import { + getActiveOwner, + readTemplate, + type Layer, + type StructureDoc, +} from "./M02_MasterTemplete_Api_Fetch"; +import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; /** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */ @@ -71,6 +77,8 @@ const TEXT = { noPlace: "이 도면 화면은 표 놓기를 아직 못 함", stale: "면적산출 값이 바뀐 표 {n}개", update: "업데이트", + tag: "{id} 도면", + tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}", updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음", } as const; @@ -112,6 +120,22 @@ const sameValues = (a: Record = {}, b: Record = return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); }; +/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */ +export async function sentRanges(at: Selection): Promise> { + try { + const got = await readTemplate(at.layer, "structure", at.name, at.projectId); + const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? []; + return rows.map((row) => ({ + 시트: row.시트, + 범위: row.범위, + 이름: fill(TEXT.tag, "id", row.연결), + 설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기), + })); + } catch { + return []; + } +} + export interface AreaToDrawingOptions { /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ open: (sel: Selection) => Promise; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 11c49c3d3..8bf976944 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -160,7 +160,9 @@ export async function pillsFor( ...(p.자재 ? { 자재: p.자재 } : {}), 고정: true, })); - return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills); + // 받기 성공 = 목록 확정 — 이 목록에 없는 연결 알약만 ⚠(받기 실패 때는 표시 안 함) + const links: LinkOptions = { 항목, 모드: "입력", 확정: true }; + return 항목.length ? { links } : { ...none(TEXT.noPills), links }; } catch (error) { return none(fill(TEXT.pillsFailed, why(error))); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 37867bab2..999937ca8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -28,7 +28,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; -import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing"; +import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; import { helpParts } from "./M02_MasterTemplete_CardShell"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; @@ -273,13 +273,14 @@ export function createMain( ): Promise { const mine = seq; try { - const [{ createSpreadsheet }, pills] = await Promise.all([ + const [{ createSpreadsheet }, pills, sent] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), pillsFor(kind, at.name), + sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리 ]); if (mine !== seq) return; if (pills.notice) showNotice([pills.notice]); - const links: LinkOptions = pills.links; + const links: LinkOptions = { ...pills.links, 표시범위: sent }; const handle: SpreadsheetHandle = createSpreadsheet( host, doc as Parameters[1], diff --git a/resources/tester/spreadsheet/harness_links.ts b/resources/tester/spreadsheet/harness_links.ts index ea6b0ff85..6572337da 100644 --- a/resources/tester/spreadsheet/harness_links.ts +++ b/resources/tester/spreadsheet/harness_links.ts @@ -6,7 +6,10 @@ import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet"; import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet"; const w = window as unknown as Record; -const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력"; +const q = new URLSearchParams(location.search); +const mode = q.get("mode") === "자재" ? "자재" : "입력"; +// ?ghost=1 — 목록 확정 + 목록에 없는 연결 알약 X(B1) · 목록 알약 L(B2) / ?range=1 — 도면 보낸 범위 A1:B2 +const ghost = q.get("ghost") === "1"; const book: Workbook = { 종류: "통합문서", @@ -29,6 +32,11 @@ const book: Workbook = { ], 활성: "s1", }; +if (ghost) + book.시트[0].연결 = [ + { 키: "X", 칸: "B1", 이름: "지운로직", 단위: "m" }, + { 키: "L", 칸: "B2", 이름: "길이", 단위: "m" }, + ]; const MATERIALS: LinkMaterial[] = [ { 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" }, @@ -59,6 +67,14 @@ w.ss = createSpreadsheet(document.getElementById("host")!, book, { { 키: "H", 이름: "높이", 단위: "m" }, { 키: "A", 이름: "면적", 단위: "m²" }, ], + ...(ghost ? { 확정: true } : {}), + ...(q.get("range") === "1" + ? { + 표시범위: [ + { 시트: "s1", 범위: "A1:B2", 이름: "T1 도면", 설명: "도면 표 T1 로 보낸 범위" }, + ], + } + : {}), onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록), 자재찾기: async (글) => { asked.push(글); diff --git a/resources/tester/test_m02_area_marks.py b/resources/tester/test_m02_area_marks.py new file mode 100644 index 000000000..f542edb8c --- /dev/null +++ b/resources/tester/test_m02_area_marks.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""50-1 면적산출 알약 · 보낸 범위 표시 — 원문 규칙 점검(화면 수치는 ui_checks/50-1_*.js 를 ORCA 로).""" + +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(HERE, "..", "..") + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def test_orphan_mark_only_when_list_confirmed(): + # ⚠ 는 호스트가 목록을 확정했을 때만 — 알약 받기 실패(확정 없음)는 표시 안 함 + links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts") + assert 'opts.모드 === "입력" && opts.확정 === true' in links + structure = _read("M02_MasterTemplete", "M02_MasterTemplete_Structure.ts") + assert "확정: true" in structure + assert structure.count("확정: true") == 1 # 실패 길(none)은 확정 없음 + + +def test_cell_pick_highlights_pill_and_range_is_unfilled(): + links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts") + assert "is-here" in links and "scrollIntoView" in links + css = _read("A00_Common", "spreadsheet", "spreadsheet_links.css") + block = css.split(".ss-link-range {", 1)[1].split("}", 1)[0] + assert "dashed" in block and "background" not in block # 채움 없음 = 알약 색과 안 겹침 + + +def test_sent_ranges_from_same_column_drawing(): + src = _read("M02_MasterTemplete", "M02_MasterTemplete_AreaToDrawing.ts") + assert 'readTemplate(at.layer, "structure", at.name, at.projectId)' in src + assert '"{id} 도면"' in src diff --git a/resources/tester/ui_checks/50-1_보낸범위_읽기.js b/resources/tester/ui_checks/50-1_보낸범위_읽기.js new file mode 100644 index 000000000..0f4353814 --- /dev/null +++ b/resources/tester/ui_checks/50-1_보낸범위_읽기.js @@ -0,0 +1,42 @@ +/* 50-1 면적 시트 「도면으로 보낸 범위」 읽기(`sentRanges`) — orca eval 한 번(서버 쓰기 없음 · 가짜 응답). + * 시작 화면: 로그인한 M02(#/m02-master-template?open=구조물조합&root=master) 아무 화면. + * 하는 일: 도면 읽기 길만 가짜 연결표(T1 · T3)로 바꿔 부름 → 그 답 · 실패 때(500) 빈 목록 → fetch 되돌림. + * 기대(out): rows = 2 · tag = ["T1 도면","T3 도면"] · tip0 = 「도면 표 T1 로 보낸 범위 A1:B2 · 글자 2.5」 · + * sheet0 = 가짜 시트 id · failed = [] . + */ +(async () => { + const mod = + await import("/@fs/C:/Aislo_wt/sub_laptop_3/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts"); + const real = window.fetch; + const at = { layer: "system", projectId: null, kind: "area", name: "item1", label: "item1" }; + const out = {}; + try { + window.fetch = async (url, init) => { + if (!String(url).includes("/templates/structure/item1")) return real(url, init); + return new Response( + JSON.stringify({ + 종류: "structure", + 이름: "item1", + 판: "1", + 문서: { + 연결표: [ + { 연결: "T1", 시트: "s9", 범위: "A1:B2", 글자크기: 2.5, 값: {} }, + { 연결: "T3", 시트: "s9", 범위: "C3:D5", 글자크기: 3, 값: {} }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + const rows = await mod.sentRanges(at); + out.rows = rows.length; + out.tag = rows.map((r) => r.이름); + out.tip0 = rows[0]?.설명; + out.sheet0 = rows[0]?.시트; + window.fetch = async () => new Response("{}", { status: 500 }); + out.failed = await mod.sentRanges(at); + } finally { + window.fetch = real; + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/50-1_알약_강조_표시.js b/resources/tester/ui_checks/50-1_알약_강조_표시.js new file mode 100644 index 000000000..10004fb5b --- /dev/null +++ b/resources/tester/ui_checks/50-1_알약_강조_표시.js @@ -0,0 +1,52 @@ +/* 50-1 면적산출 알약 · 칸 · 보낸 범위 표시 — orca eval 한 번에 도는 시나리오(저장 · 서버 없음). + * 시작 화면: /@fs/C:/Aislo_wt/sub_laptop_3/resources/tester/spreadsheet/harness_links.html?ghost=1&range=1 (알약 L · H · A · 연결 X=B1 · L=B2 · 범위 A1:B2). + * 하는 일: ① 알약 줄 ② 보낸 범위 상자 ③ 칸 B1 · B2 를 눌러 그 칸 알약 강조 ④ X 의 [×] 로 연결 풀기 + * 기대(out): orphan = X 만 ⚠ · 목록 알약 L 은 아님 · rangeTag "T1 도면" · rangeFill 투명 · rangeDashed true · + * hereB1 = ["X"] · hereB2 = ["L"] · popOnce true(칸이 바뀐 첫 그림만) · xGone true. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const q = (s) => document.querySelector(s); + const all = (s) => [...document.querySelectorAll(s)]; + const click = (el) => { + const r = el.getBoundingClientRect(); + const at = { + bubbles: true, + button: 0, + clientX: r.left + r.width / 2, + clientY: r.top + r.height / 2, + }; + const t = document.elementFromPoint(at.clientX, at.clientY) ?? el; + for (const type of ["mousedown", "mouseup", "click"]) t.dispatchEvent(new MouseEvent(type, at)); + }; + await W(800); + const out = {}; + const pill = (k) => q(`.ss-link[data-key="${k}"]`); + out.orphan = all(".ss-link.is-orphan").map((b) => b.dataset.key); + out.xText = pill("X")?.textContent; + out.lOrphan = pill("L")?.classList.contains("is-orphan"); + const range = q(".ss-link-range"); + const cs = range && getComputedStyle(range); + out.rangeTag = range?.querySelector(".ss-link-range__tag")?.textContent; + out.rangeFill = cs?.backgroundColor; + out.rangeDashed = cs?.borderTopStyle === "dashed"; + out.rangeSize = range && [Math.round(range.offsetWidth), Math.round(range.offsetHeight)]; + const byKey = (k) => all(".ss-link-cell").find((t) => t.dataset.key === k); // 칸 표시는 고를 때마다 새로 얹힘 + out.cellOrphan = byKey("X")?.classList.contains("is-orphan"); + const here = () => all(".ss-link.is-here").map((b) => b.dataset.key); + click(byKey("X")); // B1 + await W(300); + out.hereB1 = here(); + out.popOnce = all(".ss-link.is-pop").length === 1; + click(byKey("L")); // B2 + await W(300); + out.hereB2 = here(); + const doc0 = window.ss.getDoc(); + const n0 = doc0.시트[0].연결.length; + click(pill("X").querySelector(".ss-link__x")); + await W(300); + const n1 = window.ss.getDoc().시트[0].연결.length; + out.xGone = n1 === n0 - 1 && !pill("X"); + out.xCell = !all(".ss-link-cell").some((t) => t.dataset.key === "X"); + return out; +})(); From 2d954a14dddffdfddad1c2b04a473d3af1ff534b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:42:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(M01):=2050-2=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=E2=80=94=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EA=B8=B0=ED=98=B8=20=EB=B2=97=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=20=ED=95=AD=20=C2=B7=20=EC=A3=BC=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 원문 조각에서 역슬래시 · 인용 머리 · 글 안 주석 벗김(원본 자료 손 안 댐) - 출처 항 · 주 번호(3-4-2 1 · 9-7-1 주⑥)를 source() 답에 실음 — 그 조각 hit 표시 - 패널이 그 항을 옅은 바탕 + 막대로 강조 · 보이게 스크롤 · 머리에 「1항」 · 「주⑥」 - 시험 3 추가 · 화면 시나리오 50-2 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M01_MasterData/M01_MasterData_Store_Make.py | 66 +++++++++++++++-- .../M01_MasterData_UI_LogicLab_Side.css | 6 ++ .../M01_MasterData_UI_LogicLab_Source.ts | 20 +++++- resources/tester/test_m01_logic_source.py | 43 +++++++++++ .../tester/ui_checks/50-2_원문_항강조.js | 71 +++++++++++++++++++ 5 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 resources/tester/ui_checks/50-2_원문_항강조.js diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 029ce0d35..13762011a 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi # ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── -_NUMBER = re.compile(r"(?~`])") # 마크다운 이스케이프 `\.` `\-` … +_COMMENT = re.compile(r"") # 글 안 주석(글자층 표시) — 글자만 남김 +_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" + + +def _plain(text: str) -> str: + """원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `.""" + text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip() + return text[1:].strip() if text.startswith(">") else text def _blocks(lines: list[str]) -> list[dict]: @@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]: if not text or text.startswith("