diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 3432cfacf..0527c5d91 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -34,6 +34,8 @@ import type { CellStyle, Command, CommandEffect, + CondFmtRule, + DataValidationRule, Sheet, SheetCellAddress, Workbook, @@ -310,6 +312,15 @@ function insertDelete( const 범위 = shiftRange(d.범위); return 범위 ? [{ ...d, 범위 }] : []; }); + // 조건부서식 · 유효성 범위도 같이(104-2) — `remapCondFmtRanges`·`remapValidationRanges`(3단계) 와 + // 같은 규칙이지만 그 모듈을 안 끌어옴(기본 번들 크기 예산 `test_spreadsheet_size`) — 여기 그대로 적음. + const remapRanges = (rules: T[]): T[] => + rules.flatMap((rule) => { + const 범위 = rule.범위.map(shiftRange).filter((r): r is CellRange => r !== null); + return 범위.length ? [{ ...rule, 범위 }] : []; + }); + if (s.조건부서식) s.조건부서식 = remapRanges(s.조건부서식); + if (s.유효성) s.유효성 = remapRanges(s.유효성); const axis = rows ? "행" : "열"; const table = s[axis]; diff --git a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts index 9f3989db8..d96cf9df3 100644 --- a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts @@ -18,7 +18,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { dialogKeys } from "./spreadsheet_actions"; import { parseA1, toA1 } from "./spreadsheet_address"; -import type { Cell, CellRange, Sheet } from "./spreadsheet_types"; +import type { Cell, CellLink, CellRange, Command, Sheet } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { @@ -51,6 +51,55 @@ function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } { return { maxR, maxC }; } +/** 밀기 · 당기기 범위 안 알약 연결도 같이 옮김(104-2) — `axis` 범위(행이면 열 범위 · 열이면 행 범위) 안 + * 알약만 · `dir` +1 밀기(삽입) · -1 당기기(삭제, `amount` 폭만큼 지운 자리의 알약은 빠짐). 안 바뀌면 `undefined` + * (되돌리기 짝에 빈 「시트통째」를 안 끼움). */ +function shiftLinks( + sheet: Sheet, + range: CellRange, + axis: "col" | "row", + dir: 1 | -1, + amount: number, +): CellLink[] | undefined { + const list = sheet.연결; + if (!list?.length) return undefined; + const lo = axis === "col" ? range.r0 : range.c0; + const hi = axis === "col" ? range.r1 : range.c1; + const at = axis === "col" ? range.c0 : range.r0; + let changed = false; + const next = list.flatMap((link): CellLink[] => { + const p = parseA1(link.칸); + if (!p) return [link]; + const along = axis === "col" ? p.r : p.c; + const cross = axis === "col" ? p.c : p.r; + if (along < lo || along > hi || cross < at) return [link]; // 범위 밖 · 밀리는 자리 앞 — 그대로 + if (dir < 0 && cross < at + amount) return (changed = true), []; // 지운 자리 — 빠짐 + changed = true; + const moved = cross + dir * amount; + return [{ ...link, 칸: axis === "col" ? toA1(p.r, moved) : toA1(moved, p.c) }]; + }); + return changed ? next : undefined; +} + +/** 「칸」 명령(값만) 앞에 — 알약이 움직였으면 「시트통째」(연결만 바꿈)를 한 묶음으로(되돌리기 한 걸음) */ +function dispatchShift( + ctx: SpreadsheetContext, + sheet: Sheet, + range: CellRange, + axis: "col" | "row", + dir: 1 | -1, + amount: number, + cells: Record, +): void { + const cellCmd: Command = { 종류: "칸", 시트: ctx.selection.시트, 칸: cells }; + const linked = shiftLinks(sheet, range, axis, dir, amount); + ctx.dispatch( + linked + ? { 종류: "묶음", 명령: [{ 종류: "시트통째", 시트: { ...sheet, 연결: linked } }, cellCmd] } + : cellCmd, + ); +} + // ═════════════════════════════════════════════════════════════════════════════ // 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고) // ═════════════════════════════════════════════════════════════════════════════ @@ -66,7 +115,7 @@ export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): voi cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "col", 1, width, cells); } export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void { @@ -80,7 +129,7 @@ export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "row", 1, height, cells); } export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void { @@ -94,7 +143,7 @@ export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "col", -1, width, cells); } export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void { @@ -108,7 +157,7 @@ export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void { cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null; } } - ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + dispatchShift(ctx, sheet, range, "row", -1, height, cells); } // ═════════════════════════════════════════════════════════════════════════════ diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 6c3c131a7..6d3f1c637 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -250,24 +250,37 @@ outline-offset: -2px; } -/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +/* 보낸 범위(도면 표 · 104-1 · 104-3) — 점선 테두리(채움 없음) + 왼위 이름표 · 표마다 다른 색 + * (--ss-link-h · spreadsheet_link_color) · 같은 자리에 겹치면(104-3) 테두리는 안쪽으로 · 이름표는 옆으로 */ .ss-link-range { position: absolute; z-index: 3; box-sizing: border-box; - border: 2px dashed var(--color-accent); + border: 2px dashed hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); pointer-events: none; } +.ss-link-range.is-picked { + border-style: solid; + box-shadow: 0 0 0 1px hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); +} + .ss-link-range__tag { position: absolute; - left: -2px; bottom: 100%; padding: 0 4px; border-radius: 3px 3px 0 0; - background: var(--color-accent); + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); color: var(--color-canvas); font-size: 10px; line-height: 14px; white-space: nowrap; + pointer-events: auto; + cursor: pointer; +} + +.ss-link-range__tag:hover, +.ss-link-range__tag:focus-visible { + filter: brightness(1.15); + outline: none; } diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 8643d1b3f..2c9e68581 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -21,11 +21,13 @@ import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; import { clearLinksCommand, linkCommand, linksAt } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; +import { resolveName } from "./spreadsheet_names"; import { shiftSpan } from "./spreadsheet_refshift"; -import { selectCell } from "./spreadsheet_selection"; +import { selectCell, selectSpan } from "./spreadsheet_selection"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellLink, + CellRange, Command, CommandEffect, LinkMaterial, @@ -56,8 +58,17 @@ export interface LinkOptions { /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 * 목록에 없는 연결 알약에 ⚠ + [×] */ 확정?: boolean; - /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ - 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표 · 104-3) — 시트 id · 범위(이름이 없거나 지워졌으면 이 범위) · + * `범위이름`(104-1) 있으면 이름정의로 지금 범위를 다시 품(행열 넣기 · 지우기를 따라감) · 이름표 · 마우스 올림 글 · + * `취소`(있으면 이름표 메뉴에 [보내기 취소]) — 연결만 끊고(true) 화면에서 뗌 · 실패(false)면 그대로 */ + 표시범위?: { + 시트: string; + 범위: string; + 범위이름?: string; + 이름: string; + 설명?: string; + 취소?: () => Promise; + }[]; } export interface Links extends PartHandle { @@ -84,6 +95,10 @@ const TEXT = { dup: "이미 목록에 있는 자재", orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", orphanX: "연결 풀기", + rangeTip: "누르면 이 범위를 고름", + cancelSend: "보내기 취소", + cancelTip: "연결만 끊음 — 도면 표는 남음", + cancelFail: "연결을 못 끊음", }; type RowCol = Extract; @@ -158,6 +173,22 @@ export function mountLinks( let pick: HTMLElement | null = null; /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ let lastAt = ""; + /** 누른 보낸범위 테두리(104-3) — 다음에 누르면 강조를 옮김 · 다시 그릴 때 비움(DOM 이 새로 생김) */ + let pickedFrame: HTMLElement | null = null; + + /** 보낸 범위 마크의 지금 범위(104-1 · 104-3) — `범위이름` 있으면 그 이름이 지금 가리키는 범위로 + * (행열 넣기 · 지우기를 따라감) · 이름이 지워졌으면 null(서버가 다음 저장 때 연결을 끊음) · + * 이름이 없으면(옛 연결) 적힌 `범위` 그대로 */ + function markRange( + mark: NonNullable[number], + ): { 시트: string; g: CellRange } | null { + if (mark.범위이름) { + const found = resolveName(ctx.book, mark.시트, mark.범위이름); + return found && { 시트: found.시트, g: found.범위 }; + } + const g = parseRange(mark.범위); + return g && { 시트: mark.시트, g }; + } /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -386,6 +417,7 @@ export function mountLinks( function placeDots(): void { for (const d of dots) d.remove(); dots = []; + pickedFrame = null; // 테두리 DOM 이 새로 생김 — 눌린 상태는 다시 눌러야 함 const hues = linkHues(found().keys()); const listed = new Set(items.map((i) => i.키)); const sure = opts?.모드 === "입력" && opts.확정 === true; @@ -407,18 +439,31 @@ export function mountLinks( cellSlots.push({ list, layer, box }); } const rangeSlots: Array<{ - mark: { 시트: string; 범위: string; 이름: string; 설명?: string }; + mark: NonNullable[number]; + g: CellRange; layer: HTMLElement; box: ReturnType["box"]; end: ReturnType["box"]; + /** 같은 왼위 자리를 먼저 쓴 수(104-3 — 겹치면 몇째인지) */ + dup: number; + hue: number; }> = []; - for (const mark of opts?.표시범위 ?? []) { - const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; - if (!g) continue; + const marks = opts?.표시범위 ?? []; + // 색 = 범위 이름(104-1) 없으면 시트!범위 — 표마다 다르게(104-3 · 알약 색과는 다른 자리) + const rangeHues = linkHues(marks.map((m) => m.범위이름 ?? `${m.시트}!${m.범위}`)); + const dupCount = new Map(); // 왼위 자리 키 → 지금까지 그 자리에 둔 수 + for (const mark of marks) { + const found = markRange(mark); + if (!found || found.시트 !== ctx.sheet().id) continue; + const { g } = found; const { layer, box } = slot(ctx, g.r0, g.c0); const end = slot(ctx, g.r1, g.c1).box; if (!box.w || !box.h) continue; - rangeSlots.push({ mark, layer, box, end }); + const key = `${g.r0},${g.c0}`; + const dup = dupCount.get(key) ?? 0; + dupCount.set(key, dup + 1); + const hue = rangeHues.get(mark.범위이름 ?? `${mark.시트}!${mark.범위}`) ?? 0; + rangeSlots.push({ mark, g, layer, box, end, dup, hue }); } // 2) 쓰기 — 모은 자리에 한 번에 그림 @@ -441,14 +486,49 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } - // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) - for (const { mark, layer, box, end } of rangeSlots) { + // 보낸 범위(도면 표 등 · 104-1 · 104-3) — 채움 없는 점선 테두리 + 왼위 이름표 · 표마다 다른 색 · + // 같은 자리가 겹치면(dup) 테두리는 안쪽으로 · 이름표는 옆으로 비킴 · 이름표만 누를 수 있음 + for (const { mark, g, layer, box, end, dup, hue } of rangeSlots) { + const w = Math.max(box.w, end.x + end.w - box.x); + const h = Math.max(box.h, end.y + end.h - box.y); + const inset = dup * 3; + const tag = el("span", { + className: "ss-link-range__tag", + text: mark.이름, + attrs: { title: mark.설명 ?? TEXT.rangeTip, role: "button", tabindex: "0" }, + }); + tag.style.left = `${dup * 44}px`; const frame = el("div", { className: "ss-link-range", attrs: { "data-range": mark.범위 }, - children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + children: [tag], }); - 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`; + frame.style.cssText = `left:${box.x + inset}px;top:${box.y + inset}px;width:${Math.max(w - inset * 2, 0)}px;height:${Math.max(h - inset * 2, 0)}px`; + frame.style.setProperty("--ss-link-h", String(hue)); + tag.addEventListener("click", (e) => { + e.stopPropagation(); + pickedFrame?.classList.remove("is-picked"); + frame.classList.add("is-picked"); + pickedFrame = frame; + ctx.select(selectSpan(ctx.sheet(), { r: g.r0, c: g.c0 }, { r: g.r1, c: g.c1 }), true); + }); + if (mark.취소 && !ctx.readOnly) { + tag.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const o = ctx.root.getBoundingClientRect(); + menu.open(e.clientX - o.left, e.clientY - o.top, [ + [ + TEXT.cancelSend, + () => + void mark.취소!().then((ok) => { + if (!ok) return showToast(TEXT.cancelFail, "error"); + if (opts) opts.표시범위 = opts.표시범위?.filter((m) => m !== mark); + placeDots(); + }), + ] as [string, () => void], + ]); + }); + } layer.append(frame); dots.push(frame); } @@ -464,8 +544,9 @@ export function mountLinks( : []; 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.이름); + const found = markRange(m); + if (found && found.시트 === ctx.sheet().id && inRange(found.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"); diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py index 9e2ab79ff..d56f1aadb 100644 --- a/M02_MasterTemplete/M02_Area_Table.py +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -21,13 +21,21 @@ from M02_MasterTemplete.M02_Template_Layers import drawing_sheets logger = logging.getLogger(__name__) LINKS = "연결표" +DROPPED = "지운연결" +# common_util_spreadsheet_node.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭 +NAME_GONE = "이름없음" -def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> dict[str, Any]: - """그 열 면적산출(저장본) 시트 · 범위 → `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400.""" +def to_table( + column: str, sheet: str, rng: str, font: float | None = None, name: str | None = None +) -> dict[str, Any]: + """그 열 면적산출(저장본) 시트 · 범위(또는 `name` 이 있으면 그 이름이 지금 가리키는 범위) → + `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400(이름이 지워졌으면 「이름없음」).""" book = store.read("area", column)["문서"] try: - got = sheet_node.area_tables([{"book": book, "시트": sheet, "범위": rng, "글자크기": font}]) + got = sheet_node.area_tables( + [{"book": book, "시트": sheet, "범위": rng, "이름": name, "글자크기": font}] + ) except sheet_node.RecalcError as e: raise store.StoreError(503, str(e)) from e if not got[0].get("ok"): @@ -35,6 +43,37 @@ def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> di return {k: got[0][k] for k in ("표", "시트", "범위", "값")} +def define_name(column: str, sheet: str, rng: str, name: str) -> None: + """처음 도면으로 보낼 때(T 번호 정해진 뒤) 면적 시트에 보낸 범위 이름을 더함/갱신(104-1) — + `applyLinked`(행열 넣기 · 지우기)가 이 이름을 그대로 따라가게 함.""" + book = store.read("area", column)["문서"] + try: + got = sheet_node.define_names([{"book": book, "시트": sheet, "범위": rng, "이름": name}]) + except sheet_node.RecalcError as e: + raise store.StoreError(503, str(e)) from e + if not got[0].get("ok"): + raise store.StoreError(400, got[0].get("까닭")) + path = store._path("area", column) + with store._LOCK: + atomic_write_json(path, got[0]["book"]) # 디스크 글 자체가 책(`store.read` 의 "문서") + + +def unlink(column: str, 연결: str) -> None: + """이름표 메뉴 [보내기 취소](104-3) — 연결표에서 그 줄만 빼고 저장 · 도면 표(CAD 엔티티)는 그대로 남음 + (지우기는 도면에서 — `withLinks` 가 다음 저장 때 표 없는 기록을 거둠).""" + path = store._path("structure", column) + if not path.is_file(): + return + with store._LOCK: + doc = json.loads(path.read_bytes()) + rows = doc.get(LINKS) or [] + kept = [x for x in rows if x.get("연결") != 연결] + if len(kept) == len(rows): + return + doc[LINKS] = kept + atomic_write_json(path, doc) + + def _style_link(entity: Any) -> Any: if not isinstance(entity, dict) or entity.get("type") != "Table": return None @@ -42,7 +81,9 @@ def _style_link(entity: Any) -> Any: def refresh_drawing(column: str) -> list[str]: - """지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.""" + """지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록. + 이름(104-1)이 있으면 그 이름이 지금 가리키는 범위로 `범위` 도 갱신 — 이름이 지워졌으면(범위가 + 통째로 지워짐) 그 연결을 끊고(도면 표는 남음) `지운연결` 목록에 적어 다음에 열 때 알림.""" try: drawing, area = store._path("structure", column), store._path("area", column) except store.StoreError: @@ -55,7 +96,13 @@ def refresh_drawing(column: str) -> list[str]: return [] book = json.loads(area.read_bytes()) asks = [ - {"book": book, "시트": x.get("시트"), "범위": x.get("범위"), "글자크기": x.get("글자크기")} + { + "book": book, + "시트": x.get("시트"), + "범위": x.get("범위"), + "이름": x.get("이름"), + "글자크기": x.get("글자크기"), + } for x in links ] try: @@ -65,9 +112,17 @@ def refresh_drawing(column: str) -> list[str]: return [] entities = [e for _, sheet in drawing_sheets(doc) for e in sheet.get("entities") or []] done: list[str] = [] + kept: list[dict[str, Any]] = [] + dropped: list[str] = [] for link, got in zip(links, results): if not got.get("ok"): + if link.get("이름") and got.get("까닭") == NAME_GONE: + dropped.append(link["연결"]) # 이름이 통째로 지워짐 — 연결만 끊음(도면 표는 남김) + else: + kept.append(link) # 그 밖 실패(시트 지움 등)는 다음 저장 때 다시 시도 continue + kept.append(link) + link["범위"] = got["범위"] # 이름이 지금 가리키는 범위로(104-1) link["값"] = got["값"] new = got["표"]["shapeData"] for entity in entities: @@ -81,6 +136,11 @@ def refresh_drawing(column: str) -> list[str]: shape["rowHeights"], shape["columnWidths"] = new["rowHeights"], new["columnWidths"] shape["cells"] = new["cells"] done.append(link["연결"]) + if dropped: + doc[LINKS] = kept + doc[DROPPED] = sorted(set(doc.get(DROPPED) or []) | set(dropped)) + elif len(kept) != len(links): + doc[LINKS] = kept with store._LOCK: atomic_write_json(drawing, doc) return done diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 16c335f82..4dd0f674d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -159,9 +159,13 @@ export interface StructureDoc { 장?: string; 시트: string; 범위: string; + /** 보낸 범위 기준(104-1) — 면적 시트 이름정의(`도면_T1` 꼴) · 없으면(옛 연결) `범위` 를 그대로 씀 */ + 이름?: string; 글자크기: number; 값: Record; }[]; + /** 서버가 연결을 끊은 T번호(104-1 · 보낸 범위가 통째로 지워짐) — 열 때 한 번 알리고 다음 저장에서 지움 */ + 지운연결?: string[]; } /** 구조물 도면 더한 장 하나(82-2) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index e3221c45c..455284717 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -46,7 +46,7 @@ interface TableEntityJson { } /** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */ export type TableLink = NonNullable[number]; -type LinkSpec = Pick; +type LinkSpec = Pick; /** 표 변환 길 답(41-1) — 시트 = 시트 id */ interface AreaTable { 표: TableEntityJson; @@ -82,10 +82,12 @@ const TEXT = { tag: "{id} 도면", tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}", updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음", + rangeGone: "보낸 범위가 지워져 연결을 끊음 — {ids}(도면 표는 남음)", } as const; const SIZE = 2.5; // 표 글자 크기 기본(정함 41) const LINK = "연결"; // 표 style 안 표지 칸 +const NAME = "도면_"; // 보낸 범위 이름정의 앞머리(104-1 · `도면_T1` 꼴) const fill = (text: string, key: string, value: string | number): string => text.replace(`{${key}}`, String(value)); @@ -100,7 +102,12 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }), + body: JSON.stringify({ + 시트: spec.시트, + 범위: spec.범위, + 이름: spec.이름, + 글자크기: spec.글자크기, + }), }); const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial; if (!res.ok || !data.표) { @@ -110,6 +117,25 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise return data as AreaTable; } +/** 보낸 범위 이름을 면적 시트에 더함/갱신(104-1 · 처음 놓을 때 1번) — 실패해도 놓기는 이미 끝났으니 토스트만 */ +async function defineAreaName( + layer: Layer, + column: string, + 시트: string, + 범위: string, + 이름: string, +): Promise { + const owner = getActiveOwner(); + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/name${q}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 시트, 범위, 이름 }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); +} + /** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */ const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [ ["", doc.도면?.entities ?? []], @@ -128,6 +154,22 @@ const sameValues = (a: Record = {}, b: Record = return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); }; +/** 이름표 메뉴 [보내기 취소](104-3) — 연결표에서 그 줄만 빼고 저장(도면 표는 남음) */ +async function unlinkTable(at: Selection, 연결: string): Promise { + const owner = getActiveOwner(); + const q = owner && at.layer !== "system" && at.layer !== "project" ? `?owner=${enc(owner)}` : ""; + const res = await fetch( + `${API_BASE_URL}/m02/layers/${at.layer}/structures/${enc(at.name)}/unlink${q}`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 연결 }), + }, + ); + return res.ok; +} + /** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */ export async function sentRanges(at: Selection): Promise> { try { @@ -136,8 +178,10 @@ export async function sentRanges(at: Selection): Promise ({ 시트: row.시트, 범위: row.범위, + 범위이름: row.이름, 이름: fill(TEXT.tag, "id", row.연결), 설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기), + 취소: () => unlinkTable(at, row.연결).catch(() => false), })); } catch { return []; @@ -319,17 +363,23 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { else { const id = nextId(doc, new Set()); const { 표, 시트, 범위, 값 } = give.got; + const 이름 = `${NAME}${id}`; const style = { ...(표.shapeData.style ?? {}), [LINK]: id }; - fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 }); + fresh.set(id, { 연결: id, 시트, 범위, 이름, 글자크기: give.글자크기, 값 }); handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id); waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂) showToast(TEXT.place, "info"); + // 보낸 범위 이름을 면적 시트에 — 행열 넣기 · 지우기를 따라가게(104-1) · 실패해도 놓기는 그대로 + void defineAreaName(at.layer, at.name, 시트, 범위, 이름).catch(() => {}); } } + if (doc.지운연결?.length) showToast(fill(TEXT.rangeGone, "ids", doc.지운연결.join(" · ")), "info"); void checkStale(handle, at, doc); }, withLinks: (doc) => { waiting = false; + const { 지운연결: _dropped, ...doc_ } = doc; + doc = doc_; const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row])); const rows: TableLink[] = []; for (const [sheet, entities] of sheetsOf(doc)) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index da280d8c1..0f0d627bf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -311,10 +311,12 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: class AreaTableBody(BaseModel): - """면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `글자크기` 기본 2.5.""" + """면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `범위` · `이름`(104-1) 이 있으면 + 그 이름이 지금 가리키는 범위로 품(연결표 다시 읽을 때) · `글자크기` 기본 2.5.""" 시트: str 범위: str + 이름: str | None = None 글자크기: float | None = None @@ -330,7 +332,55 @@ def post_area_table( from M02_MasterTemplete import M02_Area_Table as area_table folder = _root(session, layer, owner) - return _call(folder, area_table.to_table, column, body.시트, body.범위, body.글자크기) + return _call( + folder, area_table.to_table, column, body.시트, body.범위, body.글자크기, body.이름 + ) + + +class AreaNameBody(BaseModel): + """보낸 범위 이름 정의/갱신(104-1 · 처음 도면으로 보낼 때 1번) — `이름` = `도면_T1` 꼴.""" + + 시트: str + 범위: str + 이름: str + + +@router.post("/layers/{layer}/area/{column}/name") +def post_area_name( + layer: Layer, + column: str, + body: AreaNameBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적 시트에 보낸 범위 이름을 더함/갱신 — 쓰기 권한.""" + from M02_MasterTemplete import M02_Area_Table as area_table + + folder = _root(session, layer, owner, write=True) + _call(folder, area_table.define_name, column, body.시트, body.범위, body.이름) + return {} + + +class UnlinkBody(BaseModel): + """이름표 메뉴 [보내기 취소](104-3) — `연결` = T번호.""" + + 연결: str + + +@router.post("/layers/{layer}/structures/{column}/unlink") +def post_structure_unlink( + layer: Layer, + column: str, + body: UnlinkBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """연결표에서 그 줄만 빼고 저장(104-3) — 도면 표는 남음 · 쓰기 권한.""" + from M02_MasterTemplete import M02_Area_Table as area_table + + folder = _root(session, layer, owner, write=True) + _call(folder, area_table.unlink, column, body.연결) + return {} @router.post("/layers/{layer}/{kind}/{column}/clear") diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py index 68afa4218..607f9fa9b 100644 --- a/common_util/common_util_spreadsheet.py +++ b/common_util/common_util_spreadsheet.py @@ -57,11 +57,23 @@ def with_server_values(book: dict[str, Any]) -> dict[str, Any]: def area_tables(asks: list[dict[str, Any]]) -> list[dict[str, Any]]: - """면적산출 시트 · 범위 → CAD 표 객체(41-1 · 변환은 번들 `areaTable` 한 벌) — 물음 차례 그대로 - `{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`.""" + """면적산출 시트 · 범위(또는 `이름`) → CAD 표 객체(41-1 · 104-1 · 변환은 번들 `areaTable` 한 벌) — + 물음 차례 그대로 `{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`(이름이 지워졌으면 `까닭` = 「이름없음」). + 번들을 못 돌리면 `RecalcError`.""" if _stale() and not build_bundle(NPM_SCRIPT): raise RecalcError("스프레드시트 계산 번들을 못 만듦") out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"tables": asks}) if out is None: raise RecalcError("스프레드시트 계산 번들 실행 실패") return out["tables"] + + +def define_names(asks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """면적 시트에 보낸 범위 이름을 더하거나 갱신(104-1 · 처음 도면으로 보낼 때 1번 · 번들 `defineAreaName`) — + 물음 차례 그대로 `{ok, book}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`.""" + if _stale() and not build_bundle(NPM_SCRIPT): + raise RecalcError("스프레드시트 계산 번들을 못 만듦") + out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"names": asks}) + if out is None: + raise RecalcError("스프레드시트 계산 번들 실행 실패") + return out["names"] diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index fbf7a7736..285302462 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -26,19 +26,33 @@ import { import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle"; import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph"; import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers"; +import { resolveName } from "../A00_Common/spreadsheet/spreadsheet_names"; import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; -/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */ +/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */ +export const NAME_GONE = "이름없음"; + +/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면 + * 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */ interface TableAsk { book: Workbook; 시트: string; 범위: string; + 이름?: string; 글자크기?: number; 한도?: number; } +/** 면적 시트에 보낸 범위 이름을 더하거나(같은 이름 있으면) 범위를 갱신(104-1 · 처음 보낼 때 1번) */ +interface NameAsk { + book: Workbook; + 시트: string; + 범위: string; + 이름: string; +} + /** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */ interface TableCell { text: string; @@ -64,11 +78,20 @@ const ALIGN: Partial> = { function areaTable(ask: TableAsk) { const book = ask.book; - const sheet = - book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트); - if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`); - const range = parseRange(ask.범위); - if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + let sheetId = ask.시트; + let range: CellRange; + if (ask.이름) { + const found = resolveName(book, ask.시트, ask.이름); + if (!found) throw new Error(NAME_GONE); // 이름을 지웠거나 범위가 통째로 지워짐 + sheetId = found.시트; + range = found.범위; + } else { + const parsed = parseRange(ask.범위); + if (!parsed) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + range = parsed; + } + const sheet = book.시트.find((s) => s.id === sheetId) ?? book.시트.find((s) => s.이름 === sheetId); + if (!sheet) throw new Error(`없는 시트 「${sheetId}」`); const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT; const limit = ask.한도 ?? LIMIT; const span = (a: number, b: number): number[] => @@ -146,6 +169,19 @@ function areaTable(ask: TableAsk) { return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 }; } +/** 이름정의를 더하거나(있으면) 범위만 바꿈 — 책 사본을 돌려줌(저장은 python 쪽) */ +function defineAreaName(ask: NameAsk): { book: Workbook } { + const book = ask.book; + const sheet = + book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트); + if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`); + const range = parseRange(ask.범위); + if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + const kept = (sheet.이름정의 ?? []).filter((n) => n.이름 !== ask.이름); + sheet.이름정의 = [...kept, { 이름: ask.이름, 범위: range }]; + return { book }; +} + const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { console.error("사용법: node <번들> <입력.json> <출력.json>"); @@ -155,6 +191,7 @@ if (!inputPath || !outputPath) { const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[]; tables?: TableAsk[]; + names?: NameAsk[]; }; const results = (input.books ?? []).map((book) => { try { @@ -170,4 +207,11 @@ const tables = (input.tables ?? []).map((ask) => { return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; } }); -writeFileSync(outputPath, JSON.stringify({ results, tables })); +const names = (input.names ?? []).map((ask) => { + try { + return { ok: true, ...defineAreaName(ask) }; + } catch (error) { + return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; + } +}); +writeFileSync(outputPath, JSON.stringify({ results, tables, names })); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs index 996e25fc7..e5f02b676 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs @@ -229,4 +229,22 @@ section("names", () => { eq("통째 지움 → 이름 빠짐", [b.시트[0].이름정의.length, v("B1")], [0, "#NAME?"]); }); +// ── 5. 104-2 — 행 · 열 넣기 · 지우기 때 조건부서식 · 유효성 범위도 같이(기본 번들 안 — remap 을 그대로 옮겨 적음) ── +section("condfmt_validation_follow_rowcol", () => { + const { C } = require("./helper_spreadsheet_engine.cjs"); + const b = book({ A1: 1, B1: 2 }); + b.시트[0].조건부서식 = [ + { id: "cf1", 범위: [A.parseRange("A1:A1")], 조건: { 종류: "글포함", 글: "x" }, 스타일: {} }, + ]; + b.시트[0].유효성 = [ + { id: "v1", 범위: [A.parseRange("B1:B1")], 규칙: { 종류: "정수" }, 어기면: "막음", 메시지: "" }, + ]; + C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }); + eq("조건부서식 — 행 넣은 뒤 범위", A.rangeToA1(b.시트[0].조건부서식[0].범위[0]), "A2:A2"); + eq("유효성 — 행 넣은 뒤 범위", A.rangeToA1(b.시트[0].유효성[0].범위[0]), "B2:B2"); + C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 }); // A 열 지움 + eq("조건부서식 — 범위를 다 잃으면 규칙째 빠짐", b.시트[0].조건부서식.length, 0); + eq("유효성 — B 열이 A 로 당겨짐", A.rangeToA1(b.시트[0].유효성[0].범위[0]), "A2:A2"); +}); + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_stage2_insert_dialog.ts b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts index af571c203..d84eaed61 100644 --- a/resources/tester/spreadsheet/test_stage2_insert_dialog.ts +++ b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts @@ -61,5 +61,40 @@ import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_ assertEqual(book.시트[0].칸[a1(2, 0)], undefined, "위로 당김 · 마지막 자리 빔"); } +// 104-2 — 밀기 · 당기기에도 알약 연결(`연결`)이 따라감. +// A1=1 B1=2 C1=3 · B1 에 알약 k1 · B1:B1 자리에 오른쪽으로 밀기 → 알약도 C1 로. +{ + const book = makeBook([ + makeSheet( + "s1", + { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }, + { 연결: [{ 키: "k1", 칸: a1(0, 1) }] }, + ), + ]); + const ctx = makeCtx(book, "s1"); + insertShiftRight(ctx, { r0: 0, c0: 1, r1: 0, c1: 1 }); + assertEqual(book.시트[0].연결, [{ 키: "k1", 칸: a1(0, 2) }], "오른쪽 밀기 · 알약 연결도 C1 로"); +} + +// A1=1 B1=2 C1=3 · A1 · B1 에 알약 둘 · A1:A1 삭제(왼쪽 당김) → A1 알약은 지운 자리라 빠짐 · B1 알약은 +// A1 로 당겨짐. +{ + const book = makeBook([ + makeSheet( + "s1", + { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }, + { + 연결: [ + { 키: "k1", 칸: a1(0, 0) }, + { 키: "k2", 칸: a1(0, 1) }, + ], + }, + ), + ]); + const ctx = makeCtx(book, "s1"); + deleteShiftLeft(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }); + assertEqual(book.시트[0].연결, [{ 키: "k2", 칸: a1(0, 0) }], "왼쪽 당김 · 지운 자리 알약은 빠짐 · 나머지는 당겨짐"); +} + if (process.exitCode) process.exit(process.exitCode); console.log("spreadsheet_insert_dialog 시험 끝"); diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 0f93efab7..63bf88099 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -182,6 +182,91 @@ def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없 assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록 +NAMED_SHEET = {**SHEET, "이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}]} +NAMED_BOOK = {**BOOK, "시트": [NAMED_SHEET]} + + +def test_이름으로_범위를_풂_104_1(): + got = sheet_node.area_tables([{"book": NAMED_BOOK, "시트": "s1", "범위": "", "이름": "도면_T1"}])[0] + assert got["ok"] and got["범위"] == "B1:C2" # 이름정의가 가리키는 지금 범위로(저장된 범위는 안 봄) + + +def test_이름이_지워졌으면_이름없음_104_1(): + got = sheet_node.area_tables([{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_없음"}])[0] + assert not got["ok"] and got["까닭"] == area_table.NAME_GONE + + +def test_define_names_있으면_갱신_없으면_더함_104_1(): + added = sheet_node.define_names( + [{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_T9"}] + )[0] + assert added["ok"] + names = added["book"]["시트"][0]["이름정의"] + assert {"이름": "도면_T9", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} in names + again = sheet_node.define_names( + [{"book": added["book"], "시트": "s1", "범위": "A1:A1", "이름": "도면_T9"}] + )[0] + kept = [n for n in again["book"]["시트"][0]["이름정의"] if n["이름"] == "도면_T9"] + assert kept == [{"이름": "도면_T9", "범위": {"r0": 0, "c0": 0, "r1": 0, "c1": 0}}] # 갱신(둘 아님) + + +def _named_link_drawing(folder: Path) -> None: + """연결표 T1 에 `이름`(104-1) · 이름정의가 가리키는 지금 범위는 B1:C2(저장된 범위는 A1:B2 — 낡음).""" + with store.at(folder): + got = store.read("structure", "rv") + doc = got["문서"] + doc["연결표"] = [{"연결": "T1", "시트": "s1", "범위": "A1:B2", "이름": "도면_T1", "값": {}}] + store.write("structure", "rv", got["판"], doc) + + +def test_저장_때_이름이_가리키는_범위로_갱신_104_1(world): + system = world["system"] + _named_link_drawing(system) + with store.at(system): + got = store.read("area", "rv") + doc = got["문서"] + doc["시트"][0]["이름정의"] = [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}] + store.write("area", "rv", got["판"], doc) + drawing = store.read("structure", "rv")["문서"] + assert drawing["연결표"][0]["범위"] == "B1:C2" # 옛(A1:B2) 아님 — 이름이 가리키는 지금 범위 + + +def test_이름_통째_지워지면_연결_끊고_지운연결_104_1(world): + system = world["system"] + _named_link_drawing(system) # 이름정의 없는 책 — 이름이 없는 것과 같음 + with store.at(system): + got = store.read("area", "rv") + store.write("area", "rv", got["판"], got["문서"]) # 면적 저장 → refresh_drawing + drawing = store.read("structure", "rv")["문서"] + assert drawing["연결표"] == [] # 연결 끊김(도면 표 엔티티는 안 지움) + assert drawing["지운연결"] == ["T1"] + + +def test_이름_정의_엔드포인트_104_1(world): + world["session"]["role"] = "SYSTEM_ADMIN" # system 층 쓰기 권한 + client = world["client"] + res = client.post( + "/api/m02/layers/system/area/rv/name", + json={"시트": "s1", "범위": "B1:C2", "이름": "도면_T5"}, + ) + assert res.status_code == 200, res.text + with store.at(world["system"]): + names = store.read("area", "rv")["문서"]["시트"][0]["이름정의"] + assert {"이름": "도면_T5", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} in names + + +def test_보내기_취소_엔드포인트_104_3(world): + world["session"]["role"] = "SYSTEM_ADMIN" # system 층 쓰기 권한 + system = world["system"] + _named_link_drawing(system) + client = world["client"] + res = client.post("/api/m02/layers/system/structures/rv/unlink", json={"연결": "T1"}) + assert res.status_code == 200, res.text + with store.at(system): + doc = store.read("structure", "rv")["문서"] + assert doc["연결표"] == [] # 도면 엔티티는 이 길에서 안 건드림(지우기는 도면에서) + + def test_프로젝트_층_길도_같은_맞춤(world, tmp_path): project = tmp_path / "project" import shutil diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index ff11fa2bd..045772108 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -172,7 +172,15 @@ def test_저장_연결표는_남은_표만(got): now = {"s1!B2": "1.5", "s1!C2": "2"} assert got["saved"] == [ {"연결": "T1", "장": "", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, - {"연결": "T2", "장": "", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now}, + { + "연결": "T2", + "장": "", + "시트": "s1", + "범위": "B2:C2", + "이름": "도면_T2", # 104-1 — 새로 놓은 표만 이름정의(옛 T1 기록은 그대로 범위만) + "글자크기": 2.5, + "값": now, + }, ] assert got["plain"] is False