Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_area_table.ts
|
||||
* 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 한 벌의 코드를 두 곳이 씀(CLAUDE.md 5장 ②):
|
||||
* 서버 Node(`common_util/common_util_spreadsheet_node.ts` · [저장] 때 정본) ·
|
||||
* 브라우저(M02 면적 상세 오른쪽 도면 패널 · 저장 전 실시간 표 값 · 112-1).
|
||||
* 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 ·
|
||||
* style.연결 빈 글(화면이 붙임) · 저장 안 함.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
|
||||
import { cellStyleAt } from "./spreadsheet_cellstyle";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
|
||||
import { resolveName } from "./spreadsheet_names";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import type { CellRange, HAlign, Workbook } from "./spreadsheet_types";
|
||||
|
||||
/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */
|
||||
export const NAME_GONE = "이름없음";
|
||||
|
||||
/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면
|
||||
* 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */
|
||||
export interface TableAsk {
|
||||
book: Workbook;
|
||||
시트: string;
|
||||
범위: string;
|
||||
이름?: string;
|
||||
글자크기?: number;
|
||||
한도?: number;
|
||||
}
|
||||
|
||||
/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */
|
||||
export interface TableCell {
|
||||
text: string;
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const FONT = 2.5;
|
||||
const LIMIT = 2000;
|
||||
/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */
|
||||
const COL_K = 0.7;
|
||||
const ROW_K = 1.8;
|
||||
const ALIGN: Partial<Record<HAlign, TableCell["align"]>> = {
|
||||
left: "left",
|
||||
center: "center",
|
||||
right: "right",
|
||||
distributed: "center",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
export function areaTable(ask: TableAsk) {
|
||||
const book = ask.book;
|
||||
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[] =>
|
||||
Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||
const rowShown = (r: number): boolean => {
|
||||
const info = sheet.행?.[String(r + 1)];
|
||||
return !info?.숨김 && !info?.걸러짐;
|
||||
};
|
||||
const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김;
|
||||
if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4)
|
||||
throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음
|
||||
const rows = span(range.r0, range.r1).filter(rowShown);
|
||||
const cols = span(range.c0, range.c1).filter(colShown);
|
||||
if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음");
|
||||
if (rows.length * cols.length > limit)
|
||||
throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`);
|
||||
const engine = createCalcEngine(book);
|
||||
const merges: { whole: CellRange; part: CellRange }[] = [];
|
||||
for (const text of sheet.병합 ?? []) {
|
||||
const whole = parseRange(text);
|
||||
if (!whole || !rangesOverlap(whole, range)) continue;
|
||||
const part = {
|
||||
r0: Math.max(whole.r0, range.r0),
|
||||
c0: Math.max(whole.c0, range.c0),
|
||||
r1: Math.min(whole.r1, range.r1),
|
||||
c1: Math.min(whole.c1, range.c1),
|
||||
}; // 범위 밖 병합은 잘라 냄
|
||||
merges.push({ whole, part });
|
||||
}
|
||||
const mergeAt = (r: number, c: number) =>
|
||||
merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1);
|
||||
const 값: Record<string, string> = {};
|
||||
const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => {
|
||||
const style = cellStyleAt(book, sheet, at.r, at.c);
|
||||
const has = sheet.칸[toA1(at.r, at.c)];
|
||||
const shown =
|
||||
has && (has.값 !== undefined || has.식 !== undefined)
|
||||
? formatValue(engine.value(sheet.id, at.r, at.c), style.형식)
|
||||
: { 글: "", 정렬: "left" as const };
|
||||
const key = `${sheet.id}!${toA1(r, c)}`;
|
||||
값[key] = shown.글;
|
||||
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
|
||||
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
||||
};
|
||||
const cells = rows.map((r) =>
|
||||
cols.map((c): TableCell | null => {
|
||||
const merge = mergeAt(r, c);
|
||||
if (!merge) return cellOf(r, c, { r, c });
|
||||
const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1);
|
||||
const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1);
|
||||
if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리
|
||||
const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸
|
||||
return {
|
||||
...cell,
|
||||
...(inCols.length > 1 ? { colSpan: inCols.length } : {}),
|
||||
...(inRows.length > 1 ? { rowSpan: inRows.length } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS;
|
||||
const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
|
||||
const 표 = {
|
||||
type: "Table",
|
||||
lineWidth: 1,
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 0 },
|
||||
columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K),
|
||||
rowHeights: rows.map(
|
||||
(r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K,
|
||||
),
|
||||
cells,
|
||||
style: { fontSize: font, padding: 1, 연결: "" },
|
||||
},
|
||||
};
|
||||
return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 };
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Actor } from 'xstate';
|
||||
import { TableEntity } from '../entities/TableEntity.ts';
|
||||
import { ActorEvent } from '../tools/tool.types.ts';
|
||||
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
|
||||
import {
|
||||
getEntities,
|
||||
isDrawingDirty,
|
||||
setEntities,
|
||||
setHostReadOnly,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import {
|
||||
getPendingTable,
|
||||
placeTableToolStateMachine,
|
||||
@@ -158,6 +164,27 @@ describe('표 칸 갈기(update-table)', () => {
|
||||
expect(getEntities()).toBe(before);
|
||||
});
|
||||
|
||||
it('보기 전용 도면은 막고(0) · preview 로 온 갈기만 보기 전용에서도 갈음(112-1)', async () => {
|
||||
const linked = table('T1');
|
||||
setEntities([linked]);
|
||||
setHostReadOnly(true);
|
||||
const cells = [
|
||||
[{ text: '항목' }, null],
|
||||
[{ text: '면적' }, { text: '77.0' }],
|
||||
];
|
||||
const notify = vi.fn();
|
||||
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T1', cells }, notify);
|
||||
expect(notify).toHaveBeenLastCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T1', count: 0 });
|
||||
expect(linked.getCell(1, 1)?.text).toBe('12.5');
|
||||
await handleTableMessage(
|
||||
{ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T1', cells, preview: true },
|
||||
notify
|
||||
);
|
||||
expect(notify).toHaveBeenLastCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T1', count: 1 });
|
||||
expect(linked.getCell(1, 1)?.text).toBe('77.0');
|
||||
setHostReadOnly(false);
|
||||
});
|
||||
|
||||
it('표 메시지가 아니면 false', async () => {
|
||||
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
|
||||
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
|
||||
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
|
||||
* → 부모에 table-updated{link, count}
|
||||
* update-table{link, cells, preview?} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
|
||||
* → 부모에 table-updated{link, count} · preview 면 보기 전용이어도 갈음(M02 면적 도면 패널 · 112-1)
|
||||
* place-cancelled{} (CAD → 부모) 표를 놓지 않고 Esc 로 끝남
|
||||
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
|
||||
*/
|
||||
@@ -25,6 +25,8 @@ interface TableMessage {
|
||||
entity?: JsonEntity;
|
||||
link?: string;
|
||||
cells?: TableCells;
|
||||
/** update 전용 — 보기 전용 미리보기 패널이 저장 전 값을 비춤(도면 문서는 안 고침 · 보기 전용이어도 갈음) */
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
/** 이 표가 그 연결의 것인가 */
|
||||
@@ -77,7 +79,7 @@ export async function handleTableMessage(
|
||||
return true;
|
||||
}
|
||||
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
|
||||
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
|
||||
const count = message.preview || !blocked() ? updateLinkedTables(message.link, message.cells) : 0;
|
||||
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ from M02_MasterTemplete.M02_Template_Layers import drawing_sheets
|
||||
logger = logging.getLogger(__name__)
|
||||
LINKS = "연결표"
|
||||
DROPPED = "지운연결"
|
||||
# common_util_spreadsheet_node.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭
|
||||
CANCELS = "보내기취소" # 면적 [저장] 문서의 임시 칸 — 저장 안 함(112-2)
|
||||
# spreadsheet_area_table.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭
|
||||
NAME_GONE = "이름없음"
|
||||
|
||||
|
||||
@@ -43,35 +44,69 @@ def to_table(
|
||||
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("까닭"))
|
||||
def fresh_names(path: Any, doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""도면 [저장] 문서의 새 연결 줄(저장돼 있던 파일에 없던 `연결`) 중 `이름`이 있는 것 —
|
||||
면적 문서에 둘 이름(112-2)."""
|
||||
old: set[Any] = set()
|
||||
if path.is_file():
|
||||
old = {
|
||||
x.get("연결")
|
||||
for x in json.loads(path.read_bytes()).get(LINKS) or []
|
||||
if isinstance(x, dict)
|
||||
}
|
||||
rows = [x for x in doc.get(LINKS) or [] if isinstance(x, dict)]
|
||||
return [x for x in rows if x.get("이름") and x.get("연결") not in old]
|
||||
|
||||
|
||||
def define_names(column: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""도면 [저장] 요청 안에서(112-2) 새 연결 줄의 보낸 범위 이름을 면적 시트에 더함/갱신(104-1) —
|
||||
`applyLinked`(행열 넣기 · 지우기)가 이 이름을 따라가게 함. 면적 문서가 없거나 못 풀면 건너뜀
|
||||
(이름 없는 연결은 적힌 범위로 감)."""
|
||||
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():
|
||||
if not rows or not path.is_file():
|
||||
return
|
||||
book = json.loads(path.read_bytes())
|
||||
try:
|
||||
for row in rows:
|
||||
ask = {
|
||||
"book": book,
|
||||
"시트": row.get("시트"),
|
||||
"범위": row.get("범위"),
|
||||
"이름": row["이름"],
|
||||
}
|
||||
got = sheet_node.define_names([ask])[0]
|
||||
if got.get("ok"):
|
||||
book = got["book"]
|
||||
else:
|
||||
logger.warning(
|
||||
"보낸 범위 이름 건너뜀 %s %s — %s", column, row["이름"], got.get("까닭")
|
||||
)
|
||||
except sheet_node.RecalcError as e:
|
||||
logger.warning("보낸 범위 이름 건너뜀 %s — %s", column, e)
|
||||
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)
|
||||
atomic_write_json(path, book) # 디스크 글 자체가 책(`store.read` 의 "문서")
|
||||
|
||||
|
||||
def apply_cancels(column: str, doc: Any) -> tuple[Any, list[str]]:
|
||||
"""면적 [저장] 문서의 `보내기취소[]`(112-2 · 이름표 메뉴 [보내기 취소])를 떼어 냄 — 그 연결 줄의 `이름`을
|
||||
문서의 이름정의에서 뺌 → (문서, 연결 목록). 줄을 빼는 일은 `refresh_drawing(unlink=…)`."""
|
||||
if not isinstance(doc, dict) or CANCELS not in doc:
|
||||
return doc, []
|
||||
ids = [x for x in doc[CANCELS] or [] if isinstance(x, str)]
|
||||
doc = {k: v for k, v in doc.items() if k != CANCELS}
|
||||
path = store._path("structure", column)
|
||||
rows = json.loads(path.read_bytes()).get(LINKS) or [] if ids and path.is_file() else []
|
||||
names = {x.get("이름") for x in rows if isinstance(x, dict) and x.get("연결") in ids}
|
||||
names.discard(None)
|
||||
if names:
|
||||
doc["시트"] = [
|
||||
{**s, "이름정의": [n for n in s["이름정의"] if n.get("이름") not in names]}
|
||||
if isinstance(s, dict) and s.get("이름정의")
|
||||
else s
|
||||
for s in doc.get("시트") or []
|
||||
]
|
||||
return doc, ids
|
||||
|
||||
|
||||
def _style_link(entity: Any) -> Any:
|
||||
@@ -80,8 +115,10 @@ def _style_link(entity: Any) -> Any:
|
||||
return ((entity.get("shapeData") or {}).get("style") or {}).get("연결")
|
||||
|
||||
|
||||
def refresh_drawing(column: str) -> list[str]:
|
||||
def refresh_drawing(column: str, unlink: Any = ()) -> list[str]:
|
||||
"""지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.
|
||||
`unlink` = 면적 [저장] 때 [보내기 취소]한 연결 — 그 줄을 연결표에서 뺌
|
||||
(도면 표는 남음 · 알림 없음 · 112-2).
|
||||
이름(104-1)이 있으면 그 이름이 지금 가리키는 범위로 `범위` 도 갱신 — 이름이 지워졌으면(범위가
|
||||
통째로 지워짐) 그 연결을 끊고(도면 표는 남음) `지운연결` 목록에 적어 다음에 열 때 알림."""
|
||||
try:
|
||||
@@ -91,8 +128,13 @@ def refresh_drawing(column: str) -> list[str]:
|
||||
if not drawing.is_file() or not area.is_file():
|
||||
return []
|
||||
doc = json.loads(drawing.read_bytes())
|
||||
links = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")]
|
||||
every = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")]
|
||||
links = [x for x in every if x["연결"] not in unlink]
|
||||
if not links:
|
||||
if len(links) != len(every): # 취소로 줄이 다 빠짐
|
||||
doc[LINKS] = links
|
||||
with store._LOCK:
|
||||
atomic_write_json(drawing, doc)
|
||||
return []
|
||||
book = json.loads(area.read_bytes())
|
||||
asks = [
|
||||
@@ -139,7 +181,7 @@ def refresh_drawing(column: str) -> list[str]:
|
||||
if dropped:
|
||||
doc[LINKS] = kept
|
||||
doc[DROPPED] = sorted(set(doc.get(DROPPED) or []) | set(dropped))
|
||||
elif len(kept) != len(links):
|
||||
elif len(kept) != len(every):
|
||||
doc[LINKS] = kept
|
||||
with store._LOCK:
|
||||
atomic_write_json(drawing, doc)
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
* 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료
|
||||
* (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽).
|
||||
* CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음.
|
||||
* 112-1 — 면적 칸을 고치면(저장 전) 연결 표 값이 바로 따라 바뀜: 지금 시트의 칸 글을 서버와 같은 코드
|
||||
* (`spreadsheet_area_table.ts`)로 브라우저가 만들어 CAD 연결 표 갈기 메시지(보기 전용 갈음)로 보냄 ·
|
||||
* 패널이 펼쳐진 동안 · 값이 바뀐 표만 · 도면 문서는 안 고침([저장] 때는 서버 refresh_drawing 이 정본).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { mountStructureSheets } from "./M02_MasterTemplete_Drawing";
|
||||
import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
|
||||
import { sameValues, type TableLink } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const TEXT = {
|
||||
loading: "도면 불러오는 중…",
|
||||
@@ -44,12 +49,25 @@ function hideChrome(frame: HTMLIFrameElement): void {
|
||||
export interface AreaDrawingOptions {
|
||||
/** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */
|
||||
onTable?: (table: DrawingTable) => void;
|
||||
/** 면적 화면에서 [보내기 취소]한 연결(저장 전) — 실시간 값 갈기에서 뺌 */
|
||||
cancelled?: () => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 도면 손잡이 중 실시간 갈기에 쓰는 것(`mountStructureSheets` 의 `updateTable`) */
|
||||
interface LiveHandle {
|
||||
updateTable?: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
|
||||
}
|
||||
|
||||
const LIVE_MS = 150; // 칸 고침이 이어지는 동안 모았다가 한 번에
|
||||
|
||||
export interface AreaDrawing {
|
||||
root: HTMLElement;
|
||||
/** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */
|
||||
show: () => void;
|
||||
/** 패널이 접힘 — 접힌 동안은 실시간 갈기 건너뜀 */
|
||||
hide: () => void;
|
||||
/** 면적 시트가 바뀜(저장 전) — 지금 책을 주면 연결 표 값을 따라 바꿈 */
|
||||
sync: (book: () => Workbook | null | undefined) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
@@ -64,6 +82,12 @@ export function createAreaDrawing(
|
||||
let frame: HTMLIFrameElement | null = null;
|
||||
let started = false;
|
||||
let dead = false;
|
||||
let open = false;
|
||||
let rows: TableLink[] = [];
|
||||
let bookOf: (() => Workbook | null | undefined) | null = null;
|
||||
let timer: number | undefined;
|
||||
/** 연결별로 마지막에 도면에 비춘 칸 글(없으면 저장된 `값`) */
|
||||
const pushed = new Map<string, Record<string, string>>();
|
||||
const note = (text: string): void =>
|
||||
stage.replaceChildren(el("p", { className: "m02-apanel__note", text }));
|
||||
|
||||
@@ -85,8 +109,38 @@ export function createAreaDrawing(
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
/** 지금 시트 → 연결 표마다 칸 글을 만들어 달라진 것만 도면에 비춤(서버 `areaTable` 과 같은 코드) */
|
||||
async function push(): Promise<void> {
|
||||
const book = bookOf?.();
|
||||
const handle = view as LiveHandle | null;
|
||||
if (dead || !open || !book || !handle?.updateTable || !rows.length) return;
|
||||
const { areaTable } = await import("../A00_Common/spreadsheet/spreadsheet_area_table");
|
||||
for (const row of rows) {
|
||||
if (opts.cancelled?.().has(row.연결)) continue;
|
||||
try {
|
||||
const got = areaTable({
|
||||
book,
|
||||
시트: row.시트,
|
||||
범위: row.범위,
|
||||
이름: row.이름,
|
||||
글자크기: row.글자크기,
|
||||
});
|
||||
if (sameValues(pushed.get(row.연결) ?? row.값, got.값)) continue;
|
||||
pushed.set(row.연결, got.값);
|
||||
await handle.updateTable(row.연결, got.표.shapeData.cells, true).catch(() => 0);
|
||||
} catch {
|
||||
// 이름이 지워짐 · 범위 못 바꿈 — 표는 그대로(저장 때 서버가 판정)
|
||||
}
|
||||
}
|
||||
}
|
||||
const later = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void push(), LIVE_MS);
|
||||
};
|
||||
|
||||
const show = (): void => {
|
||||
if (started) return;
|
||||
open = true;
|
||||
if (started) return later(); // 접힌 사이 바뀐 값을 따라잡음
|
||||
started = true;
|
||||
note(TEXT.loading);
|
||||
readTemplate(layer, "structure", column, null)
|
||||
@@ -95,6 +149,7 @@ export function createAreaDrawing(
|
||||
const doc = got.문서 as StructureDoc | null;
|
||||
if (!doc?.도면) return note(TEXT.none);
|
||||
stage.replaceChildren();
|
||||
rows = doc.연결표 ?? [];
|
||||
// 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀
|
||||
const sheets = doc as unknown as Parameters<typeof mountStructureSheets>[1];
|
||||
view = mountStructureSheets(stage, sheets, {
|
||||
@@ -111,6 +166,7 @@ export function createAreaDrawing(
|
||||
frame = stage.querySelector("iframe");
|
||||
if (frame) hideChrome(frame);
|
||||
send.disabled = !frame;
|
||||
later();
|
||||
})
|
||||
.catch(() => !dead && note(TEXT.none));
|
||||
};
|
||||
@@ -118,8 +174,11 @@ export function createAreaDrawing(
|
||||
return {
|
||||
root,
|
||||
show,
|
||||
hide: () => (open = false),
|
||||
sync: (book) => ((bookOf = book), later()),
|
||||
destroy: () => {
|
||||
dead = true;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("message", onMessage);
|
||||
view?.destroy();
|
||||
root.remove();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`)
|
||||
* · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3)
|
||||
* · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기)
|
||||
* · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`)
|
||||
* · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) ·
|
||||
* 도면 패널의 연결 표 값도 저장 전에 바로 따라 바뀜(112-1 · `AreaDrawing.sync`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
@@ -36,6 +37,8 @@ export interface AreaPanelOptions {
|
||||
/** 알약 막대 항목 — 칸 지정 때 연결에 같이 적을 이름 · 단위 · 자재 */
|
||||
pills: () => LinkItem[];
|
||||
readOnly: boolean;
|
||||
/** 면적 화면에서 [보내기 취소]한 연결(저장 전) — 도면 패널 실시간 갈기에서 뺌 */
|
||||
cancelled?: () => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface AreaPanel {
|
||||
@@ -99,6 +102,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
|
||||
// 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음
|
||||
const drawing = createAreaDrawing(opts.layer, opts.column, {
|
||||
onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table),
|
||||
cancelled: opts.cancelled,
|
||||
});
|
||||
const panels = createSidePanels({
|
||||
prefKey: "m02-area-panels",
|
||||
@@ -108,13 +112,16 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
|
||||
{ id: "labor", title: TEXT.labor, body: view.root },
|
||||
{ id: "drawing", title: TEXT.drawing, body: drawing.root },
|
||||
],
|
||||
onChange: (open) => open === "drawing" && drawing.show(),
|
||||
onChange: (open) => (open === "drawing" ? drawing.show() : drawing.hide()),
|
||||
});
|
||||
if (panels.current() === "drawing") drawing.show();
|
||||
const root = panels.root;
|
||||
|
||||
// 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번)
|
||||
const refresh = (): void => view.setQty(quantities(opts.sheet()), cellsOf(opts.sheet()));
|
||||
const refresh = (): void => {
|
||||
view.setQty(quantities(opts.sheet()), cellsOf(opts.sheet()));
|
||||
drawing.sync(() => opts.sheet()?.getDoc()); // 저장 전 칸 값 → 도면 패널 표(112-1)
|
||||
};
|
||||
void view.show(opts.layer, opts.column, quantities(opts.sheet()));
|
||||
refresh(); // 칸 주소는 지금 시트 것 — 보기가 저장본 대신 씀
|
||||
return {
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5)
|
||||
* → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열
|
||||
* 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2)
|
||||
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
|
||||
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
|
||||
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 이름, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
|
||||
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) ·
|
||||
* 새 연결 줄의 보낸 범위 이름은 이 [저장] 요청 안에서 서버가 면적 문서에 둠(112-2 · 놓고 저장 안 하면 안 생김)
|
||||
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
|
||||
* ④ 표 여러 개 — id 마다 한 줄
|
||||
* ⑥ 이름표 메뉴 [보내기 취소] — 화면에서만 점선 · 이름표를 빼고 면적 화면 「저장 안 한 고침」 ·
|
||||
* 면적 [저장] 문서에 `보내기취소[]` 를 실어 서버가 그 연결표 줄 · 이름을 뺌(도면 표는 남음 · 112-2)
|
||||
* ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id
|
||||
* (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장
|
||||
* ========================================================================== */
|
||||
@@ -117,25 +120,6 @@ 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<void> {
|
||||
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 ?? []],
|
||||
@@ -149,29 +133,19 @@ const linkOf = (e: TableEntityJson): string | null => {
|
||||
return typeof id === "string" && id ? id : null;
|
||||
};
|
||||
/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */
|
||||
const sameValues = (a: Record<string, string> = {}, b: Record<string, string> = {}): boolean => {
|
||||
export const sameValues = (
|
||||
a: Record<string, string> = {},
|
||||
b: Record<string, string> = {},
|
||||
): boolean => {
|
||||
const ka = Object.keys(a);
|
||||
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
||||
};
|
||||
|
||||
/** 이름표 메뉴 [보내기 취소](104-3) — 연결표에서 그 줄만 빼고 저장(도면 표는 남음) */
|
||||
async function unlinkTable(at: Selection, 연결: string): Promise<boolean> {
|
||||
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<NonNullable<LinkOptions["표시범위"]>> {
|
||||
export async function sentRanges(
|
||||
at: Selection,
|
||||
onCancel: (연결: string) => void,
|
||||
): Promise<NonNullable<LinkOptions["표시범위"]>> {
|
||||
try {
|
||||
const got = await readTemplate(at.layer, "structure", at.name, at.projectId);
|
||||
const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? [];
|
||||
@@ -181,7 +155,7 @@ export async function sentRanges(at: Selection): Promise<NonNullable<LinkOptions
|
||||
범위이름: row.이름,
|
||||
이름: fill(TEXT.tag, "id", row.연결),
|
||||
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
|
||||
취소: () => unlinkTable(at, row.연결).catch(() => false),
|
||||
취소: async () => (onCancel(row.연결), true), // 화면에서만 — 서버는 면적 [저장] 때
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
@@ -208,8 +182,14 @@ export interface AreaToDrawing {
|
||||
onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void;
|
||||
/** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */
|
||||
waiting: () => boolean;
|
||||
/** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */
|
||||
/** 떠나기로 함(물음 [확인]) · 저장 끝 — 기다리던 표 · 저장 전 보내기 취소를 버림 */
|
||||
drop: () => void;
|
||||
/** 이름표 메뉴 [보내기 취소] — 저장 전 목록에 담음(면적 화면 「저장 안 한 고침」) */
|
||||
cancel: (연결: string) => void;
|
||||
/** 저장 전 보내기 취소한 연결 */
|
||||
cancelled: () => ReadonlySet<string>;
|
||||
/** 면적 저장 문서 — 취소한 연결이 있으면 `보내기취소[]` 를 실음(서버가 줄 · 이름을 뺌) */
|
||||
withCancels: <T extends object>(doc: T) => T;
|
||||
/** 도면에서 Esc 로 놓지 않고 그만둠 — 기다림을 풀고 그 보내기 기록도 뺌 */
|
||||
cancelPlace: () => void;
|
||||
/** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */
|
||||
@@ -227,6 +207,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
/** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */
|
||||
let waiting = false;
|
||||
let waitingId = "";
|
||||
/** 면적 화면에서 [보내기 취소]한 연결 — 면적 [저장] 때 서버가 끊음 */
|
||||
const cancels = new Set<string>();
|
||||
|
||||
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
|
||||
const askSize = (spec: { 범위: string }): Promise<number | null> =>
|
||||
@@ -339,14 +321,21 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
|
||||
return {
|
||||
select: (s) => (range = { 시트: s.시트, 범위: s.범위 }),
|
||||
waiting: () => waiting,
|
||||
drop: () => (waiting = false),
|
||||
waiting: () => waiting || cancels.size > 0,
|
||||
drop: () => ((waiting = false), cancels.clear()),
|
||||
cancel: (연결) => void cancels.add(연결),
|
||||
cancelled: () => cancels,
|
||||
withCancels: (doc) => {
|
||||
const { 보내기취소: _old, ...rest } = doc as typeof doc & { 보내기취소?: unknown }; // 앞 저장 것은 떼고
|
||||
return (cancels.size ? { ...rest, 보내기취소: [...cancels] } : rest) as typeof doc;
|
||||
},
|
||||
cancelPlace: () => {
|
||||
waiting = false;
|
||||
fresh.delete(waitingId);
|
||||
},
|
||||
onArea: (at, readOnly, slot) => {
|
||||
waiting = false;
|
||||
cancels.clear();
|
||||
// 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림
|
||||
if (readOnly || at.layer === "project") return;
|
||||
const button = createButton({ label: TEXT.send }); // 머리 일반 단추(43-5 · 돌아가기와 같은 높이)
|
||||
@@ -360,6 +349,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
const handle = raw as TableHandle;
|
||||
shown = `${at.layer}:${at.name}`;
|
||||
range = null; // 면적 상세를 떠남
|
||||
cancels.clear();
|
||||
fresh = new Map();
|
||||
latest = new Map();
|
||||
const give =
|
||||
@@ -377,11 +367,11 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
|
||||
waitingId = id;
|
||||
waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂)
|
||||
showToast(TEXT.place, "info");
|
||||
// 보낸 범위 이름을 면적 시트에 — 행열 넣기 · 지우기를 따라가게(104-1) · 실패해도 놓기는 그대로
|
||||
void defineAreaName(at.layer, at.name, 시트, 범위, 이름).catch(() => {});
|
||||
// 보낸 범위 이름(104-1)은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(112-2 · 여기서 안 씀)
|
||||
}
|
||||
}
|
||||
if (doc.지운연결?.length) showToast(fill(TEXT.rangeGone, "ids", doc.지운연결.join(" · ")), "info");
|
||||
if (doc.지운연결?.length)
|
||||
showToast(fill(TEXT.rangeGone, "ids", doc.지운연결.join(" · ")), "info");
|
||||
void checkStale(handle, at, doc);
|
||||
},
|
||||
withLinks: (doc) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface DrawingTemplateHandle {
|
||||
getDoc: () => Promise<DrawingTemplateDoc>;
|
||||
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
|
||||
/** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */
|
||||
loadSheet: (
|
||||
drawing: DrawingTemplateDoc,
|
||||
@@ -537,7 +537,7 @@ export function mountStructureSheets(
|
||||
drawing.placeTable(entity, link);
|
||||
},
|
||||
// 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3)
|
||||
updateTable: async (link, cells) => {
|
||||
updateTable: async (link, cells, preview) => {
|
||||
const now = tabs.active();
|
||||
let count = 0;
|
||||
for (const tab of tabs.tabs()) {
|
||||
@@ -547,7 +547,7 @@ export function mountStructureSheets(
|
||||
count += changed;
|
||||
}
|
||||
if (count) report();
|
||||
return count + (await drawing.updateTable(link, cells).catch(() => 0));
|
||||
return count + (await drawing.updateTable(link, cells, preview).catch(() => 0));
|
||||
},
|
||||
destroy: drawing.destroy,
|
||||
};
|
||||
|
||||
@@ -337,52 +337,6 @@ def post_area_table(
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
def post_clear(
|
||||
layer: Layer,
|
||||
|
||||
@@ -345,7 +345,9 @@ async def save_layer_template(
|
||||
return await asyncio.to_thread(
|
||||
_store_save, folder, kind, name, body.판 or "", document, layer
|
||||
)
|
||||
cancels: list[str] = []
|
||||
if kind == "area":
|
||||
document, cancels = await asyncio.to_thread(_apply_cancels, folder, name, document)
|
||||
# 면적산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음
|
||||
# (브라우저 값 버림 · 5장 ②)
|
||||
try:
|
||||
@@ -369,15 +371,23 @@ async def save_layer_template(
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가)
|
||||
await asyncio.to_thread(_refresh_drawing, folder, name)
|
||||
await asyncio.to_thread(_refresh_drawing, folder, name, cancels)
|
||||
return {"종류": kind, "이름": name, "판": version, "층": layer}
|
||||
|
||||
|
||||
def _refresh_drawing(folder: Path, column: str) -> None:
|
||||
def _apply_cancels(folder: Path, column: str, doc: Any) -> tuple[Any, list[str]]:
|
||||
"""면적 저장 문서의 `보내기취소[]`(112-2)를 떼고 그 이름을 이름정의에서 뺌."""
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
with store.at(folder):
|
||||
area_table.refresh_drawing(column)
|
||||
return area_table.apply_cancels(column, doc)
|
||||
|
||||
|
||||
def _refresh_drawing(folder: Path, column: str, unlink: list[str] | tuple[()] = ()) -> None:
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
with store.at(folder):
|
||||
area_table.refresh_drawing(column, unlink)
|
||||
|
||||
|
||||
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
|
||||
|
||||
@@ -150,8 +150,15 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
||||
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`).
|
||||
도면↔면적(112-2) — 구조물 도면 저장 때 새 연결 줄의 보낸 범위 이름을 같은 요청 안에서 면적 문서에 두고 ·
|
||||
면적 저장 문서의 `보내기취소[]` 연결은 도면 연결표 줄 · 이름을 뺌."""
|
||||
path = _path(kind, name)
|
||||
cancels: list[str] = []
|
||||
if kind == "area":
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
doc, cancels = area_table.apply_cancels(name, doc)
|
||||
_check_skeleton(kind, doc)
|
||||
if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3)
|
||||
doc = {**doc, "키": name}
|
||||
@@ -159,10 +166,15 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
doc = _server(kind, doc)
|
||||
fresh: list[dict[str, Any]] = []
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise StoreError(409, {"stale": [name], "판": have})
|
||||
if kind == "structure":
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
fresh = area_table.fresh_names(path, doc)
|
||||
if screen and have and kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes())))
|
||||
if have and kind in _KEY:
|
||||
@@ -172,7 +184,11 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
info = _info(kind, path)
|
||||
if kind == "area":
|
||||
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
|
||||
_refresh_drawing(name) # 도면 연결표 칸 글도(41-1)
|
||||
_refresh_drawing(name, cancels) # 도면 연결표 칸 글도(41-1) · 보내기 취소 줄 뺌(112-2)
|
||||
if fresh:
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
area_table.define_names(name, fresh) # 새 연결 줄의 보낸 범위 이름(112-2)
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
|
||||
for part in _KEY:
|
||||
@@ -562,11 +578,12 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any:
|
||||
return doc
|
||||
|
||||
|
||||
def _refresh_drawing(column: str) -> None:
|
||||
"""그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1)."""
|
||||
def _refresh_drawing(column: str, unlink: list[str] | tuple[()] = ()) -> None:
|
||||
"""그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1) ·
|
||||
`unlink` = 보내기 취소 줄."""
|
||||
from M02_MasterTemplete import M02_Area_Table as area_table
|
||||
|
||||
area_table.refresh_drawing(column)
|
||||
area_table.refresh_drawing(column, unlink)
|
||||
|
||||
|
||||
def _refresh_labor(column: str) -> None:
|
||||
|
||||
@@ -298,7 +298,7 @@ export function createMain(
|
||||
const [{ createSpreadsheet }, pills, sent] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
pillsFor(kind, at.name),
|
||||
sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리
|
||||
sentRanges(at, toDrawing.cancel), // 도면으로 보낸 범위 — 시트 위 점선 테두리
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills.notice) showNotice([pills.notice]);
|
||||
@@ -332,6 +332,7 @@ export function createMain(
|
||||
picked: () => picked,
|
||||
pills: () => links.항목,
|
||||
readOnly,
|
||||
cancelled: toDrawing.cancelled,
|
||||
});
|
||||
host.append(apanel.root);
|
||||
return;
|
||||
@@ -555,12 +556,16 @@ export function createMain(
|
||||
...(edited as Pick<StructureDoc, "도면" | "더한장">),
|
||||
})
|
||||
: at.kind === "area" && loaded
|
||||
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
|
||||
? toDrawing.withCancels({
|
||||
...(loaded as Record<string, unknown>),
|
||||
...(edited as Record<string, unknown>),
|
||||
}) // + 저장 전 [보내기 취소] 연결(112-2)
|
||||
: edited;
|
||||
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
dirty = false;
|
||||
toDrawing.drop(); // 놓기 기다림 · 보내기 취소는 이 저장으로 끝
|
||||
// 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로
|
||||
// (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸)
|
||||
if (panel && sheet) {
|
||||
|
||||
@@ -9,43 +9,19 @@
|
||||
* 입력 { books?: Workbook[], tables?: TableAsk[] }
|
||||
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[],
|
||||
* tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂
|
||||
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 ·
|
||||
* 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함.
|
||||
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 풀이는 `A00_Common/spreadsheet/spreadsheet_area_table.ts`
|
||||
* (화면 도면 패널의 저장 전 실시간 표 값과 같은 코드 · 112-1) · 저장 안 함.
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
colName,
|
||||
parseRange,
|
||||
rangeToA1,
|
||||
rangesOverlap,
|
||||
toA1,
|
||||
} from "../A00_Common/spreadsheet/spreadsheet_address";
|
||||
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 { parseRange } from "../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { areaTable, type TableAsk } from "../A00_Common/spreadsheet/spreadsheet_area_table";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(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번) */
|
||||
/** 면적 시트에 보낸 범위 이름을 더하거나(같은 이름 있으면) 범위를 갱신(104-1 · 도면 [저장] 때 새 연결 줄마다) */
|
||||
interface NameAsk {
|
||||
book: Workbook;
|
||||
시트: string;
|
||||
@@ -53,122 +29,6 @@ interface NameAsk {
|
||||
이름: string;
|
||||
}
|
||||
|
||||
/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */
|
||||
interface TableCell {
|
||||
text: string;
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const FONT = 2.5;
|
||||
const LIMIT = 2000;
|
||||
/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */
|
||||
const COL_K = 0.7;
|
||||
const ROW_K = 1.8;
|
||||
const ALIGN: Partial<Record<HAlign, TableCell["align"]>> = {
|
||||
left: "left",
|
||||
center: "center",
|
||||
right: "right",
|
||||
distributed: "center",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
function areaTable(ask: TableAsk) {
|
||||
const book = ask.book;
|
||||
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[] =>
|
||||
Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||
const rowShown = (r: number): boolean => {
|
||||
const info = sheet.행?.[String(r + 1)];
|
||||
return !info?.숨김 && !info?.걸러짐;
|
||||
};
|
||||
const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김;
|
||||
if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4)
|
||||
throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음
|
||||
const rows = span(range.r0, range.r1).filter(rowShown);
|
||||
const cols = span(range.c0, range.c1).filter(colShown);
|
||||
if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음");
|
||||
if (rows.length * cols.length > limit)
|
||||
throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`);
|
||||
const engine = createCalcEngine(book);
|
||||
const merges: { whole: CellRange; part: CellRange }[] = [];
|
||||
for (const text of sheet.병합 ?? []) {
|
||||
const whole = parseRange(text);
|
||||
if (!whole || !rangesOverlap(whole, range)) continue;
|
||||
const part = {
|
||||
r0: Math.max(whole.r0, range.r0),
|
||||
c0: Math.max(whole.c0, range.c0),
|
||||
r1: Math.min(whole.r1, range.r1),
|
||||
c1: Math.min(whole.c1, range.c1),
|
||||
}; // 범위 밖 병합은 잘라 냄
|
||||
merges.push({ whole, part });
|
||||
}
|
||||
const mergeAt = (r: number, c: number) =>
|
||||
merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1);
|
||||
const 값: Record<string, string> = {};
|
||||
const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => {
|
||||
const style = cellStyleAt(book, sheet, at.r, at.c);
|
||||
const has = sheet.칸[toA1(at.r, at.c)];
|
||||
const shown =
|
||||
has && (has.값 !== undefined || has.식 !== undefined)
|
||||
? formatValue(engine.value(sheet.id, at.r, at.c), style.형식)
|
||||
: { 글: "", 정렬: "left" as const };
|
||||
const key = `${sheet.id}!${toA1(r, c)}`;
|
||||
값[key] = shown.글;
|
||||
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
|
||||
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
||||
};
|
||||
const cells = rows.map((r) =>
|
||||
cols.map((c): TableCell | null => {
|
||||
const merge = mergeAt(r, c);
|
||||
if (!merge) return cellOf(r, c, { r, c });
|
||||
const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1);
|
||||
const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1);
|
||||
if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리
|
||||
const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸
|
||||
return {
|
||||
...cell,
|
||||
...(inCols.length > 1 ? { colSpan: inCols.length } : {}),
|
||||
...(inRows.length > 1 ? { rowSpan: inRows.length } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS;
|
||||
const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
|
||||
const 표 = {
|
||||
type: "Table",
|
||||
lineWidth: 1,
|
||||
shapeData: {
|
||||
origin: { x: 0, y: 0 },
|
||||
columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K),
|
||||
rowHeights: rows.map(
|
||||
(r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K,
|
||||
),
|
||||
cells,
|
||||
style: { fontSize: font, padding: 1, 연결: "" },
|
||||
},
|
||||
};
|
||||
return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 };
|
||||
}
|
||||
|
||||
/** 이름정의를 더하거나(있으면) 범위만 바꿈 — 책 사본을 돌려줌(저장은 python 쪽) */
|
||||
function defineAreaName(ask: NameAsk): { book: Workbook } {
|
||||
const book = ask.book;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""면적 상세 오른쪽 도면 패널 — 저장 전 실시간 표 값(PLAN 112-1) — Node 로 TS 를 돌려 판정.
|
||||
|
||||
못박는 것
|
||||
- 브라우저 `areaTable`(`spreadsheet_area_table.ts`)이 서버 Node 번들 `area_tables` 와 같은 표 · 값 · 까닭을 냄
|
||||
(한 벌의 코드 — 서버 파일이 그 파일을 가져다 씀).
|
||||
- `AreaDrawing.sync` — 패널이 펼쳐진 동안만 · 값이 바뀐 연결 표만 · CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 ·
|
||||
이름이 지워진 표는 건너뜀 · 보내기 취소한 연결은 뺌 · 접었다 펴면 따라잡음 · 도면 문서는 안 건드림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util import common_util_spreadsheet as sheet_node
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
SHEET = {
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {
|
||||
"A1": {"값": "제목", "서식": 2},
|
||||
"B1": {"값": 1234.5, "서식": 1},
|
||||
"A2": {"값": "합"},
|
||||
"B2": {"식": "B1*2", "서식": 1},
|
||||
"A3": {"값": "숨김행"},
|
||||
"C1": {"값": "x"},
|
||||
},
|
||||
"병합": ["A1:A2", "C1:E5"],
|
||||
"행": {"3": {"숨김": True}},
|
||||
"열": {"D": {"숨김": True}, "B": {"폭": 12}},
|
||||
"이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}],
|
||||
}
|
||||
BOOK = {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "rv",
|
||||
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
|
||||
"시트": [SHEET],
|
||||
"활성": "s1",
|
||||
}
|
||||
ASKS = [
|
||||
{"시트": "면적산출", "범위": "A1:B2"},
|
||||
{"시트": "s1", "범위": "A1:D4", "글자크기": 5},
|
||||
{"시트": "s1", "범위": "", "이름": "도면_T1"},
|
||||
{"시트": "s1", "범위": "A1", "이름": "도면_없음"},
|
||||
{"시트": "없음", "범위": "A1"},
|
||||
{"시트": "s1", "범위": "가나"},
|
||||
{"시트": "s1", "범위": "A3"},
|
||||
]
|
||||
|
||||
LOADER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = process.argv[1];
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const cache = new Map();
|
||||
function loadTs(file, stubs = {}) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full).exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const mod = { exports: {} };
|
||||
cache.set(full, mod);
|
||||
const req = (r) => {
|
||||
if (r in stubs) return stubs[r];
|
||||
if (r.endsWith(".css")) return {};
|
||||
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
|
||||
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
return require(r);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, req);
|
||||
return mod.exports;
|
||||
}
|
||||
const SS = (name) => path.join(ROOT, "A00_Common", "spreadsheet", `${name}.ts`);
|
||||
"""
|
||||
|
||||
TABLE_SCRIPT = (
|
||||
LOADER
|
||||
+ r"""
|
||||
const { areaTable } = loadTs(SS("spreadsheet_area_table"));
|
||||
const { book, asks } = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const out = asks.map((ask) => {
|
||||
try {
|
||||
return { ok: true, ...areaTable({ book: structuredClone(book), ...ask }) };
|
||||
} catch (e) {
|
||||
return { ok: false, 까닭: e.message };
|
||||
}
|
||||
});
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
|
||||
LIVE_SCRIPT = (
|
||||
LOADER
|
||||
+ r"""
|
||||
const base = JSON.parse(process.argv[2]);
|
||||
const book = (cells = {}) => ({ ...base, 시트: [{ ...base.시트[0], 칸: { ...base.시트[0].칸, ...cells } }] });
|
||||
const rows = [
|
||||
{ 연결: "T1", 시트: "s1", 범위: "A1:B2", 이름: "도면_T1", 글자크기: 2.5, 값: {} },
|
||||
{ 연결: "T2", 시트: "s1", 범위: "A1:A1", 이름: "도면_없음", 글자크기: 2.5, 값: {} },
|
||||
{ 연결: "T3", 시트: "s1", 범위: "B1:B1", 글자크기: 2.5, 값: {} },
|
||||
];
|
||||
const same = (a = {}, b = {}) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const calls = [];
|
||||
const handle = {
|
||||
updateTable: async (link, cells, preview) => (calls.push({ link, cells, preview }), 1),
|
||||
destroy() {},
|
||||
};
|
||||
const fake = (tag, o = {}) => ({ tag, dataset: {}, ...o, append() {}, replaceChildren() {}, querySelector: () => null, remove() {}, addEventListener() {} });
|
||||
globalThis.window = { addEventListener() {}, removeEventListener() {}, location: { origin: "" }, setTimeout, clearTimeout };
|
||||
const { createAreaDrawing } = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_AreaDrawing.ts"), {
|
||||
"@ui/ui_template_elements": { createButton: (o) => fake("button", o), el: fake },
|
||||
"./M02_MasterTemplete_Api_Fetch": { readTemplate: async () => ({ 문서: { 도면: { entities: [] }, 연결표: rows } }) },
|
||||
"./M02_MasterTemplete_Drawing": { mountStructureSheets: () => handle },
|
||||
"./M02_MasterTemplete_AreaToDrawing": { sameValues: same },
|
||||
});
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const take = () => calls.splice(0).map((c) => [c.link, c.preview, c.cells.at(-1)[0]?.text ?? null]);
|
||||
|
||||
(async () => {
|
||||
const cancelled = new Set();
|
||||
const ad = createAreaDrawing("system", "rv", { cancelled: () => cancelled });
|
||||
const log = {};
|
||||
ad.sync(() => book());
|
||||
await wait(400);
|
||||
log.closed = take(); // 패널이 한 번도 안 열림 — 건너뜀
|
||||
ad.show();
|
||||
await wait(500);
|
||||
log.opened = take(); // 열리면 따라잡음 — 이름이 지워진 T2 는 건너뜀
|
||||
ad.sync(() => book());
|
||||
await wait(400);
|
||||
log.same = take(); // 같은 값 — 도면에 안 보냄
|
||||
ad.sync(() => book({ B1: { 값: 10, 서식: 1 } }));
|
||||
await wait(400);
|
||||
log.edited = take(); // 값이 바뀐 표만
|
||||
ad.hide();
|
||||
ad.sync(() => book({ B1: { 값: 20, 서식: 1 } }));
|
||||
await wait(400);
|
||||
log.hidden = take(); // 접힌 동안은 건너뜀
|
||||
ad.show();
|
||||
await wait(400);
|
||||
log.again = take(); // 다시 펴면 따라잡음
|
||||
cancelled.add("T1");
|
||||
ad.sync(() => book({ B1: { 값: 30, 서식: 1 } }));
|
||||
await wait(400);
|
||||
log.cancelled = take(); // 보내기 취소한 연결은 뺌
|
||||
ad.destroy();
|
||||
console.log(JSON.stringify(log));
|
||||
})();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def node() -> str:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
return "node"
|
||||
|
||||
|
||||
def test_브라우저_표_변환은_서버_번들과_같은_표_값_까닭(node, tmp_path):
|
||||
src = tmp_path / "ask.json"
|
||||
src.write_text(json.dumps({"book": BOOK, "asks": ASKS}, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
[node, "-e", TABLE_SCRIPT, str(ROOT), str(src)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
browser = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
server = sheet_node.area_tables([{"book": BOOK, **ask} for ask in ASKS])
|
||||
assert browser == server
|
||||
assert [x["ok"] for x in browser] == [True, True, True, False, False, False, False]
|
||||
assert browser[2]["범위"] == "B1:C2" # 이름정의가 가리키는 지금 범위
|
||||
|
||||
|
||||
def test_서버_번들은_그_파일을_가져다_씀():
|
||||
src = (ROOT / "common_util/common_util_spreadsheet_node.ts").read_text("utf-8")
|
||||
assert 'from "../A00_Common/spreadsheet/spreadsheet_area_table"' in src
|
||||
assert "function areaTable" not in src # 두 벌로 갈라지지 않음
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live(node):
|
||||
proc = subprocess.run(
|
||||
[node, "-e", LIVE_SCRIPT, str(ROOT), json.dumps(BOOK, ensure_ascii=False)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_패널이_닫혀_있으면_건너뜀(live):
|
||||
assert live["closed"] == []
|
||||
|
||||
|
||||
def test_열리면_따라잡음_보기_전용_갈음_이름_지워진_표는_건너뜀(live):
|
||||
# [연결, preview, 마지막 줄 첫 칸 글] — T1 = B1:C2 이름 범위 · T3 = B1 한 칸
|
||||
assert [x[:2] for x in live["opened"]] == [["T1", True], ["T3", True]]
|
||||
assert live["opened"][0][2] == "2,469.00"
|
||||
|
||||
|
||||
def test_같은_값이면_안_보내고_바뀐_표만_보냄(live):
|
||||
assert live["same"] == []
|
||||
assert [x[0] for x in live["edited"]] == ["T1", "T3"] # B1 이 T1 · T3 범위 안
|
||||
assert live["edited"][0][2] == "20.00" # 식 칸 B2 = B1*2
|
||||
|
||||
|
||||
def test_접은_동안은_건너뛰고_다시_펴면_따라잡음(live):
|
||||
assert live["hidden"] == []
|
||||
assert [x[0] for x in live["again"]] == ["T1", "T3"]
|
||||
assert live["again"][0][2] == "40.00"
|
||||
|
||||
|
||||
def test_보내기_취소한_연결은_뺌(live):
|
||||
assert [x[0] for x in live["cancelled"]] == ["T3"]
|
||||
@@ -182,17 +182,26 @@ 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_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" # 이름정의가 가리키는 지금 범위로(저장된 범위는 안 봄)
|
||||
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]
|
||||
got = sheet_node.area_tables(
|
||||
[{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_없음"}]
|
||||
)[0]
|
||||
assert not got["ok"] and got["까닭"] == area_table.NAME_GONE
|
||||
|
||||
|
||||
@@ -207,7 +216,9 @@ def test_define_names_있으면_갱신_없으면_더함_104_1():
|
||||
[{"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}}] # 갱신(둘 아님)
|
||||
assert kept == [
|
||||
{"이름": "도면_T9", "범위": {"r0": 0, "c0": 0, "r1": 0, "c1": 0}}
|
||||
] # 갱신(둘 아님)
|
||||
|
||||
|
||||
def _named_link_drawing(folder: Path) -> None:
|
||||
@@ -225,7 +236,9 @@ def test_저장_때_이름이_가리키는_범위로_갱신_104_1(world):
|
||||
with store.at(system):
|
||||
got = store.read("area", "rv")
|
||||
doc = got["문서"]
|
||||
doc["시트"][0]["이름정의"] = [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}]
|
||||
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) 아님 — 이름이 가리키는 지금 범위
|
||||
@@ -236,35 +249,108 @@ def test_이름_통째_지워지면_연결_끊고_지운연결_104_1(world):
|
||||
_named_link_drawing(system) # 이름정의 없는 책 — 이름이 없는 것과 같음
|
||||
with store.at(system):
|
||||
got = store.read("area", "rv")
|
||||
got["문서"]["시트"][0]["이름정의"] = [] # 면적에서 이름을 지움
|
||||
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 _area_names(folder: Path) -> list[dict]:
|
||||
doc = json.loads((folder / "area" / "rv.json").read_bytes())
|
||||
return doc["시트"][0].get("이름정의") or []
|
||||
|
||||
|
||||
def test_보내기_취소_엔드포인트_104_3(world):
|
||||
world["session"]["role"] = "SYSTEM_ADMIN" # system 층 쓰기 권한
|
||||
def _save_links(rows: list[dict], version: str | None = None, entities: list | None = None) -> None:
|
||||
got = store.read("structure", "rv")
|
||||
doc = got["문서"]
|
||||
doc["연결표"] = rows
|
||||
if entities is not None:
|
||||
doc["도면"]["entities"] = entities
|
||||
store.write("structure", "rv", got["판"] if version is None else version, doc)
|
||||
|
||||
|
||||
T1_ROW = {"연결": "T1", "시트": "s1", "범위": "B1:C2", "이름": "도면_T1", "글자크기": 2.5, "값": {}}
|
||||
T1_NAME = {"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}
|
||||
|
||||
|
||||
def test_도면_저장_때_새_연결_줄_이름이_같은_요청_안에서_면적_문서에_생김_112_2(world):
|
||||
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
|
||||
before = (system / "area" / "rv.json").read_bytes()
|
||||
with store.at(system):
|
||||
doc = store.read("structure", "rv")["문서"]
|
||||
assert doc["연결표"] == [] # 도면 엔티티는 이 길에서 안 건드림(지우기는 도면에서)
|
||||
assert (system / "area" / "rv.json").read_bytes() == before # 저장 전 = 서버 면적 그대로
|
||||
_save_links([T1_ROW])
|
||||
assert T1_NAME in _area_names(system) # 저장 뒤 이름
|
||||
# 면적에서 이름을 지운 뒤 같은 줄로 다시 저장 — 새 줄만 이름을 둠(도로 만들지 않음)
|
||||
path = system / "area" / "rv.json"
|
||||
book = json.loads(path.read_bytes())
|
||||
book["시트"][0]["이름정의"] = []
|
||||
path.write_text(json.dumps(book, ensure_ascii=False), encoding="utf-8")
|
||||
_save_links([T1_ROW, {**T1_ROW, "연결": "T2", "이름": "도면_T2"}])
|
||||
names = [n["이름"] for n in _area_names(system)]
|
||||
assert names == ["도면_T2"]
|
||||
|
||||
|
||||
def test_판_충돌로_도면_저장이_막히면_이름도_안_생김_112_2(world):
|
||||
system = world["system"]
|
||||
before = (system / "area" / "rv.json").read_bytes()
|
||||
with store.at(system), pytest.raises(store.StoreError) as stale:
|
||||
_save_links([T1_ROW], version="낡은판")
|
||||
assert stale.value.status == 409
|
||||
assert (system / "area" / "rv.json").read_bytes() == before
|
||||
|
||||
|
||||
def test_이름_없는_옛_연결_줄은_이름을_안_둠_112_2(world):
|
||||
system = world["system"]
|
||||
with store.at(system):
|
||||
_save_links([{"연결": "T1", "시트": "s1", "범위": "A1:B2", "값": {}}])
|
||||
assert _area_names(system) == []
|
||||
|
||||
|
||||
def test_면적_저장_보내기취소_줄과_이름을_뺌_도면_표는_남음_112_2(world):
|
||||
system = world["system"]
|
||||
t2 = {**T1_ROW, "연결": "T2", "이름": "도면_T2"}
|
||||
old = {"연결": "T3", "시트": "s1", "범위": "A1:A1", "값": {}} # 이름 없는 옛 연결
|
||||
with store.at(system):
|
||||
table = {
|
||||
"type": "Table",
|
||||
"shapeData": {"cells": [[{"text": "x"}]], "style": {"연결": "T1"}},
|
||||
}
|
||||
_save_links([T1_ROW, t2, old], entities=[table]) # 도면 표(T1) 있음
|
||||
got = store.read("area", "rv")
|
||||
# 저장 전 — 서버 도면 연결표는 그대로(취소는 화면에서만)
|
||||
assert [x["연결"] for x in store.read("structure", "rv")["문서"]["연결표"]] == [
|
||||
"T1",
|
||||
"T2",
|
||||
"T3",
|
||||
]
|
||||
doc = {**got["문서"], "보내기취소": ["T1", "T3"]}
|
||||
store.write("area", "rv", got["판"], doc)
|
||||
drawing = store.read("structure", "rv")["문서"]
|
||||
area = store.read("area", "rv")["문서"]
|
||||
assert [x["연결"] for x in drawing["연결표"]] == [
|
||||
"T2"
|
||||
] # T1 · T3 줄 빠짐(남은 줄은 지금 범위로)
|
||||
assert "지운연결" not in drawing # 취소는 「범위가 지워져」 알림이 아님
|
||||
assert drawing["도면"]["entities"] # 도면 표는 안 지움
|
||||
assert [n["이름"] for n in area["시트"][0]["이름정의"]] == ["도면_T2"] # T1 이름 뺌
|
||||
assert "보내기취소" not in area # 임시 칸은 저장 안 함
|
||||
|
||||
|
||||
def test_프로젝트_층_저장도_보내기취소를_뗌_112_2(world, tmp_path):
|
||||
import shutil
|
||||
|
||||
project = tmp_path / "project"
|
||||
shutil.copytree(world["system"], project)
|
||||
with store.at(project):
|
||||
_save_links([T1_ROW])
|
||||
book = json.loads((project / "area" / "rv.json").read_bytes())
|
||||
book["시트"][0]["이름정의"] = [T1_NAME]
|
||||
book["보내기취소"] = ["T1"]
|
||||
cut, ids = layers_router._apply_cancels(project, "rv", book)
|
||||
assert ids == ["T1"] and "보내기취소" not in cut and cut["시트"][0]["이름정의"] == []
|
||||
layers_router._refresh_drawing(project, "rv", ids)
|
||||
assert json.loads((project / "structure" / "rv.json").read_bytes())["연결표"] == []
|
||||
|
||||
|
||||
def test_프로젝트_층_길도_같은_맞춤(world, tmp_path):
|
||||
|
||||
@@ -50,11 +50,17 @@ const stubs = {
|
||||
return res;
|
||||
},
|
||||
},
|
||||
"./M02_MasterTemplete_Api_Fetch": { getActiveOwner: () => null },
|
||||
"./M02_MasterTemplete_Api_Fetch": {
|
||||
getActiveOwner: () => null,
|
||||
readTemplate: async () => ({ 문서: { 연결표: [
|
||||
{ 연결: "T1", 시트: "s1", 범위: "A1:A1", 이름: "도면_T1", 글자크기: 3, 값: {} },
|
||||
{ 연결: "T2", 시트: "s1", 범위: "B1:B1", 글자크기: 3, 값: {} },
|
||||
] } }),
|
||||
},
|
||||
};
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { createAreaToDrawing } = box.exports;
|
||||
const { createAreaToDrawing, sentRanges } = box.exports;
|
||||
|
||||
const table = (texts, style = {}) => ({
|
||||
type: "Table",
|
||||
@@ -138,7 +144,21 @@ globalThis.fetch = async (url, init) => {
|
||||
await slot.kids[0].on.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
|
||||
console.log(JSON.stringify({ esc, ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
// 112-2 — 이름은 도면 [저장] 요청 안에서 서버가 두니 놓을 때 서버를 안 부름(표 변환 길만)
|
||||
const urls = calls.map((c) => c[0]);
|
||||
// 112-2 — [보내기 취소]: 화면에서만(서버 안 부름) · 저장 안 한 고침 · 면적 저장 문서에 보내기취소[] · 저장 뒤 비움
|
||||
const callsBefore = calls.length;
|
||||
const marks = await sentRanges(area, (id) => ad.cancel(id));
|
||||
const cancelOk = await marks[0].취소();
|
||||
const cancelWaiting = ad.waiting();
|
||||
const cancelSet = [...ad.cancelled()];
|
||||
const withKey = ad.withCancels({ 열: "item1" });
|
||||
const second = ad.withCancels({ 열: "item1", 보내기취소: ["옛"] }); // 앞 저장 것은 떼고 다시 담음
|
||||
ad.cancel("T2");
|
||||
const both = ad.withCancels({ 열: "item1" }).보내기취소;
|
||||
ad.drop();
|
||||
const cancel = { ok: cancelOk, serverCalls: calls.length - callsBefore, waiting: cancelWaiting, set: cancelSet, withKey, second, both, after: ad.waiting(), none: ad.withCancels({ 열: "item1", 보내기취소: ["옛"] }), labels: marks.map((m) => [m.이름, m.범위이름 ?? null, !!m.취소]) };
|
||||
console.log(JSON.stringify({ urls, cancel, esc, ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -203,10 +223,30 @@ def test_다른_열_도면에는_안_놓음(got):
|
||||
assert got["other"] == 0
|
||||
|
||||
|
||||
def test_이름은_놓을_때_서버에_안_씀_도면_저장_때_서버가_둠_112_2(got):
|
||||
assert all(url.endswith("/table") for url in got["urls"]) # …/name 을 부르는 길이 없음
|
||||
src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts").read_text("utf-8")
|
||||
assert "defineAreaName" not in src and "/unlink" not in src and "/name" not in src
|
||||
assert got["saved"][1]["이름"] == "도면_T2" # 새 줄 이름은 저장 문서 연결표에 실려 감
|
||||
|
||||
|
||||
def test_보내기_취소는_화면에서만_저장_안_한_고침_저장에_실려_감_112_2(got):
|
||||
cancel = got["cancel"]
|
||||
assert cancel["labels"] == [["T1 도면", "도면_T1", True], ["T2 도면", None, True]]
|
||||
assert cancel["ok"] is True and cancel["serverCalls"] == 0 # 서버 안 부름
|
||||
assert cancel["waiting"] is True and cancel["set"] == ["T1"] # 면적 화면 「저장 안 한 고침」
|
||||
assert cancel["withKey"] == {"열": "item1", "보내기취소": ["T1"]}
|
||||
assert cancel["second"] == {"열": "item1", "보내기취소": ["T1"]} # 앞 저장 칸은 떼고 다시
|
||||
assert cancel["both"] == ["T1", "T2"]
|
||||
assert cancel["after"] is False # 저장 · 떠남(drop) 뒤 비움
|
||||
assert cancel["none"] == {"열": "item1"}
|
||||
|
||||
|
||||
def test_편집_칸은_갈고리만():
|
||||
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인) · cancelPlace(Esc)
|
||||
assert main.count("toDrawing.") == 7
|
||||
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인 · 저장 끝) ×2 ·
|
||||
# cancelPlace(Esc) · cancel · cancelled · withCancels
|
||||
assert main.count("toDrawing.") == 11
|
||||
assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0]
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
* 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
/** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */
|
||||
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
|
||||
updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
|
||||
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
|
||||
destroy: () => void;
|
||||
}
|
||||
@@ -201,12 +201,17 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
const placeTable: CadHost<D, M, Q>["placeTable"] = (entity, link) =>
|
||||
postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link });
|
||||
|
||||
const updateTable: CadHost<D, M, Q>["updateTable"] = (link, cells) =>
|
||||
const updateTable: CadHost<D, M, Q>["updateTable"] = (link, cells, preview) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const waits = tableWaits.get(link) ?? [];
|
||||
waits.push(resolve);
|
||||
tableWaits.set(link, waits);
|
||||
postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells });
|
||||
postAfterLoad({
|
||||
type: CAD_UPDATE_TABLE_MESSAGE,
|
||||
link,
|
||||
cells,
|
||||
...(preview ? { preview: true } : {}),
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
const left = tableWaits.get(link) ?? [];
|
||||
if (!left.includes(resolve)) return;
|
||||
|
||||
Reference in New Issue
Block a user