feat(m02): 도면 → 면적 칸 크기 기본 · 넣을 자리 고르기(105-1)
- 도면 표 받은 뒤 작은 창 [새 시트] / [고른 칸부터] · 칸 크기 · 글 크기는 기본(도면 mm 안 옮김) - 스프레드시트 공개 손잡이 pasteTable — 붙여넣기 길(parseClipboard + pasteBlock) · 덮을 값 · 알약 칸 셈(pasteHits) - 값 · 알약 이은 칸을 덮으면 물음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
import "./spreadsheet.css";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor, slotOf } from "./spreadsheet_editor";
|
||||
import { toCalcValue } from "./spreadsheet_eval";
|
||||
@@ -86,6 +86,9 @@ export interface SpreadsheetHandle {
|
||||
* `activate` 면 새 시트로 · 탭 줄 없이 띄운 시트면 탭 줄을 붙임(시트 1장으로 돌아가면 숨김) ·
|
||||
* 붙인 시트 이름 · 읽기만이면 null */
|
||||
addSheet(sheet: Sheet, opts?: { activate?: boolean }): string | null;
|
||||
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
|
||||
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
|
||||
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -344,6 +347,7 @@ export function createSpreadsheet(
|
||||
else for (const p of others) p.refresh();
|
||||
return 이름;
|
||||
},
|
||||
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
|
||||
@@ -461,7 +461,7 @@ function findBlockingMerge(sheet: Sheet, target: CellRange): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
|
||||
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): boolean {
|
||||
const target: CellRange = {
|
||||
r0: at.r,
|
||||
c0: at.c,
|
||||
@@ -471,7 +471,7 @@ function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress):
|
||||
const sheet = ctx.sheet();
|
||||
if (findBlockingMerge(sheet, target)) {
|
||||
showToast(st("ClipboardMergeBlocked"), "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// 붙일 자리가 기존 병합을 통째로 덮으면 먼저 풂(엑셀도 큰 덩어리를 얹으면 병합이 풀리고 칸마다 값이 옴) —
|
||||
// 안 풀면 A `applyCommand` 가 병합 속 왼위 아닌 칸에 값을 넣으려 할 때 막음.
|
||||
@@ -512,6 +512,42 @@ function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress):
|
||||
});
|
||||
}
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 덮을 자리에 이미 있는 것 — 값 · 식 있는 칸 수 · 알약 이은 칸 수(105-1 덮어쓰기 물음) */
|
||||
export function pasteHits(sheet: Sheet, target: CellRange): { 칸: number; 연결: number } {
|
||||
let 칸 = 0;
|
||||
for (const [a1, cell] of Object.entries(sheet.칸))
|
||||
if (inside(target, a1) && (cell.식 !== undefined || (cell.값 ?? "") !== "")) 칸 += 1;
|
||||
return { 칸, 연결: (sheet.연결 ?? []).filter((l) => inside(target, l.칸)).length };
|
||||
}
|
||||
|
||||
export type PasteCheck = (hit: {
|
||||
칸: number;
|
||||
연결: number;
|
||||
범위: string;
|
||||
}) => boolean | Promise<boolean>;
|
||||
|
||||
/** HTML 표를 지금 시트 활성 칸(왼위)부터 — 붙여넣기와 같은 길 · 표 안 빈 칸은 비움(덮어쓰기) ·
|
||||
* `check` 가 false 면 안 붙임 · 붙인 범위 A1 · 못 하면 null */
|
||||
export async function pasteTableAt(
|
||||
ctx: SpreadsheetContext,
|
||||
html: string,
|
||||
check?: PasteCheck,
|
||||
): Promise<string | null> {
|
||||
if (ctx.readOnly) return null;
|
||||
const at = { ...ctx.selection.활성 };
|
||||
const sheetId = ctx.selection.시트;
|
||||
const block = parseClipboard(html, "", at);
|
||||
if (!block?.rows || !block.cols) return null;
|
||||
for (let r = 0; r < block.rows; r += 1)
|
||||
for (let c = 0; c < block.cols; c += 1) block.칸[`${r},${c}`] ??= {};
|
||||
const target = { r0: at.r, c0: at.c, r1: at.r + block.rows - 1, c1: at.c + block.cols - 1 };
|
||||
const 범위 = `${toA1(target.r0, target.c0)}:${toA1(target.r1, target.c1)}`;
|
||||
if (check && !(await check({ ...pasteHits(ctx.sheet(), target), 범위 }))) return null;
|
||||
if (ctx.selection.시트 !== sheetId) return null; // 묻는 사이 시트가 바뀜
|
||||
return pasteBlock(ctx, block, at) ? 범위 : null;
|
||||
}
|
||||
|
||||
/** A1 칸이 범위 안인가 */
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaFromDrawing.ts
|
||||
* 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를
|
||||
* 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영.
|
||||
* 면적 통합문서로 — 받은 뒤 작은 창 [새 시트](「도면에서 n」) / [고른 칸부터](지금 시트 · 덮으면 물음 · 105-1).
|
||||
* 병합만 옮김 — 칸 크기 · 글 크기는 기본(도면 크기 안 옮김).
|
||||
* 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm).
|
||||
* 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시).
|
||||
* 싣기는 공개 손잡이 — 새 시트 `addSheet` · 고른 칸 `pasteTable`(붙여넣기 길) · 둘 다 되돌리기 한 번 · onChange.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type { PasteCheck } from "../A00_Common/spreadsheet/spreadsheet_clipboard";
|
||||
import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const TEXT = {
|
||||
done: (name: string) =>
|
||||
`도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
|
||||
pasted: (range: string) => `도면 표를 ${range} 에 넣음 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
|
||||
loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인",
|
||||
where: (rows: number, cols: number) => `도면 표(${rows}행 × ${cols}열)를 어디에 넣을지`,
|
||||
cancel: "취소",
|
||||
atCell: "고른 칸부터",
|
||||
newSheet: "새 시트",
|
||||
overwrite: (range: string, cells: number, links: number) =>
|
||||
`${range} 에 값 있는 칸 ${cells}개${links ? ` · 알약 이은 칸 ${links}개` : ""} — 덮어씀?`,
|
||||
overwriteOk: "덮어쓰기",
|
||||
noHandle: "스프레드시트가 시트 추가를 아직 못 함 — 옮기지 않음",
|
||||
noSheet: "면적산출 시트가 열려 있지 않음",
|
||||
};
|
||||
@@ -28,12 +38,6 @@ export interface DrawingTable {
|
||||
|
||||
export const SHEET_PREFIX = "도면에서";
|
||||
|
||||
/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */
|
||||
const CHAR_PER_MM = 0.54;
|
||||
const PT_PER_MM = 72 / 25.4;
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
|
||||
const round1 = (v: number): number => Math.round(v * 10) / 10;
|
||||
|
||||
/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */
|
||||
function colName(c: number): string {
|
||||
let s = "";
|
||||
@@ -59,7 +63,7 @@ function nextSheetId(book: Pick<Workbook, "시트">): string {
|
||||
return `s${n}`;
|
||||
}
|
||||
|
||||
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */
|
||||
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것 · 칸 크기 · 글 크기는 기본) */
|
||||
export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">): Sheet {
|
||||
const 칸: Record<string, Cell> = {};
|
||||
table.rows.forEach((line, r) =>
|
||||
@@ -73,39 +77,123 @@ export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">
|
||||
.map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`);
|
||||
const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 };
|
||||
if (병합.length) sheet.병합 = 병합;
|
||||
if (table.colWidths.length) {
|
||||
sheet.열 = {};
|
||||
table.colWidths.forEach((w, c) => {
|
||||
if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) };
|
||||
});
|
||||
}
|
||||
if (table.rowHeights.length) {
|
||||
sheet.행 = {};
|
||||
table.rowHeights.forEach((h, r) => {
|
||||
if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) };
|
||||
});
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */
|
||||
const escape = (v: string | number): string =>
|
||||
String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
/** 표 자료 → `<table>` 글(병합 = rowspan · colspan) — 손잡이 `pasteTable` 이 붙여넣기 길로 읽음 */
|
||||
export function tableToHtml(table: DrawingTable): string {
|
||||
const cols = Math.max(0, ...table.rows.map((line) => line.length));
|
||||
const span = new Map<string, DrawingTable["merges"][number]>();
|
||||
const covered = new Set<string>();
|
||||
for (const m of table.merges) {
|
||||
span.set(`${m.row},${m.col}`, m);
|
||||
for (let r = m.row; r < m.row + m.rowSpan; r += 1)
|
||||
for (let c = m.col; c < m.col + m.colSpan; c += 1)
|
||||
if (r !== m.row || c !== m.col) covered.add(`${r},${c}`);
|
||||
}
|
||||
const rows = table.rows.map((line, r) => {
|
||||
let tds = "";
|
||||
for (let c = 0; c < cols; c += 1) {
|
||||
if (covered.has(`${r},${c}`)) continue;
|
||||
const m = span.get(`${r},${c}`);
|
||||
const attrs =
|
||||
(m && m.rowSpan > 1 ? ` rowspan="${m.rowSpan}"` : "") +
|
||||
(m && m.colSpan > 1 ? ` colspan="${m.colSpan}"` : "");
|
||||
tds += `<td${attrs}>${escape(line[c] ?? "")}</td>`;
|
||||
}
|
||||
return `<tr>${tds}</tr>`;
|
||||
});
|
||||
return `<table>${rows.join("")}</table>`;
|
||||
}
|
||||
|
||||
export type Where = "sheet" | "cell" | null;
|
||||
|
||||
/** 넣을 자리 작은 창 — [취소] [고른 칸부터] [새 시트](주 단추 · 기본) · Esc = 취소 */
|
||||
export function askWhere(rows: number, cols: number): Promise<Where> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const done = (got: Where): void => {
|
||||
overlay.remove();
|
||||
resolve(got);
|
||||
};
|
||||
const main = createButton({
|
||||
label: TEXT.newSheet,
|
||||
variant: "filled",
|
||||
onClick: () => done("sheet"),
|
||||
});
|
||||
const actions = el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
|
||||
createButton({ label: TEXT.atCell, onClick: () => done("cell") }),
|
||||
main,
|
||||
],
|
||||
});
|
||||
overlay.dataset.areaWhere = "";
|
||||
overlay.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation();
|
||||
done(null);
|
||||
});
|
||||
overlay.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__panel",
|
||||
children: [
|
||||
el("p", { className: "ui-confirm__message", text: TEXT.where(rows, cols) }),
|
||||
actions,
|
||||
],
|
||||
}),
|
||||
);
|
||||
document.body.append(overlay);
|
||||
main.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 덮을 칸(값 · 알약)이 있으면 물음 */
|
||||
const askOverwrite: PasteCheck = (hit) =>
|
||||
!hit.칸 && !hit.연결
|
||||
? true
|
||||
: showConfirmDialog(TEXT.overwrite(hit.범위, hit.칸, hit.연결), TEXT.overwriteOk);
|
||||
|
||||
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 새 시트(탭 줄은 부품 몫) · 고른 칸부터 붙이기 */
|
||||
export interface AddSheetHandle {
|
||||
getDoc(): Workbook;
|
||||
addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown;
|
||||
pasteTable?: (html: string, check?: PasteCheck) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */
|
||||
export function importDrawingTable(
|
||||
/** 받은 표 → 넣을 자리 물음 → 새 시트(활성) 또는 지금 시트 고른 칸부터 · 알림 —
|
||||
* 옮긴 시트 이름 · 붙인 범위 · 못 하거나 그만두면 null */
|
||||
export async function importDrawingTable(
|
||||
handle: AddSheetHandle | null,
|
||||
table: DrawingTable,
|
||||
): string | null {
|
||||
choose: (rows: number, cols: number) => Promise<Where> = askWhere,
|
||||
): Promise<string | null> {
|
||||
if (!handle?.addSheet) {
|
||||
showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error");
|
||||
return null;
|
||||
}
|
||||
const sheet = tableToSheet(table, handle.getDoc());
|
||||
handle.addSheet(sheet, { activate: true });
|
||||
showToast(TEXT.done(sheet.이름), "success");
|
||||
const cols = Math.max(0, ...table.rows.map((line) => line.length));
|
||||
const where = await choose(table.rows.length, cols);
|
||||
if (!where) return null;
|
||||
let done: string | null;
|
||||
if (where === "cell") {
|
||||
if (!handle.pasteTable) {
|
||||
showToast(TEXT.noHandle, "error");
|
||||
return null;
|
||||
}
|
||||
done = await handle.pasteTable(tableToHtml(table), askOverwrite);
|
||||
if (!done) return null;
|
||||
showToast(TEXT.pasted(done), "success");
|
||||
} else {
|
||||
const sheet = tableToSheet(table, handle.getDoc());
|
||||
handle.addSheet(sheet, { activate: true });
|
||||
done = sheet.이름;
|
||||
showToast(TEXT.done(done), "success");
|
||||
}
|
||||
if (table.kind === "text") showToast(TEXT.loose, "info");
|
||||
return sheet.이름;
|
||||
return done;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 도면 표 고른 칸부터 붙이기(105-1) — 덮을 자리 셈 `pasteHits` 시험 진입 · vite --ssr 로 번들해 Node 로 돌림.
|
||||
* 결과 = JSON {pass, fail, failures} 한 줄(`test_m02_area_from_drawing_place.py` 가 읽음) */
|
||||
import { pasteHits } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard";
|
||||
import type { CellRange, Sheet } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
const failures: string[] = [];
|
||||
const eq = (name: string, got: unknown, want: unknown): void => {
|
||||
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
|
||||
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
|
||||
};
|
||||
|
||||
const sheet = {
|
||||
id: "s1",
|
||||
이름: "면적산출",
|
||||
칸: {
|
||||
B2: { 값: 3 },
|
||||
C3: { 식: "B2*2" },
|
||||
D4: { 값: "" }, // 빈 값 = 덮을 것 없음
|
||||
E9: { 값: 1 }, // 범위 밖
|
||||
},
|
||||
연결: [
|
||||
{ 키: "깔기|㎡", 칸: "C2" },
|
||||
{ 키: "치기|㎥", 칸: "F1" }, // 범위 밖
|
||||
],
|
||||
} as unknown as Sheet;
|
||||
const R = (r0: number, c0: number, r1: number, c1: number): CellRange => ({ r0, c0, r1, c1 });
|
||||
|
||||
eq("B2:D4 값 2 · 알약 1", pasteHits(sheet, R(1, 1, 3, 3)), { 칸: 2, 연결: 1 });
|
||||
eq("빈 자리", pasteHits(sheet, R(10, 0, 12, 2)), { 칸: 0, 연결: 0 });
|
||||
eq("알약만", pasteHits(sheet, R(0, 5, 0, 5)), { 칸: 0, 연결: 1 });
|
||||
eq("연결 없는 시트", pasteHits({ ...sheet, 연결: undefined }, R(0, 0, 9, 9)), {
|
||||
칸: 3,
|
||||
연결: 0,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
못박는 것
|
||||
- 표 자료(CAD `table-from-drawing.ts` 꼴) → 새 시트 「도면에서 n」(안 쓴 가장 작은 n · 안 쓴 id) · 기존 시트 안 덮음.
|
||||
- 숫자 칸 = 수 · 빈 칸 안 적음 · 병합 = A1 범위 · 열 폭(글자) · 행 높이(pt) = 도면 mm 환산 · 폴백(text) = 폭 · 높이 없음.
|
||||
- 싣기 = 공개 손잡이 addSheet(시트, {activate}) 한 번 · 손잡이 없으면 안 옮기고 알림.
|
||||
- 숫자 칸 = 수 · 빈 칸 안 적음 · 병합 = A1 범위 · 칸 크기 · 글 크기는 기본(도면 mm 안 옮김 · 105-1).
|
||||
- [새 시트] 고름 → 공개 손잡이 addSheet(시트, {activate}) 한 번 · 손잡이 없으면 안 옮기고 알림.
|
||||
- 패널 메시지 이름 = CAD `aislo-table-export.ts` 와 같음 · 읽기 면적은 보내기 단추 없음 · 시트 2장 이상이면 탭 줄.
|
||||
"""
|
||||
|
||||
@@ -29,6 +29,8 @@ const stubs = { "@ui/ui_template_elements": { showToast: (m, k) => toasts.push([
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { tableToSheet, nextSheetName, importDrawingTable } = box.exports;
|
||||
(async () => {
|
||||
const sheetPick = async () => "sheet";
|
||||
|
||||
const book = { 시트: [{ id: "s1", 이름: "면적" }, { id: "s2", 이름: "도면에서 1" }] };
|
||||
const grid = {
|
||||
@@ -47,12 +49,13 @@ out.names = [nextSheetName({ 시트: [] }), nextSheetName(book), nextSheetName({
|
||||
|
||||
const added = [];
|
||||
const handle = { getDoc: () => book, addSheet: (s, o) => added.push([s, o]) };
|
||||
out.imported = importDrawingTable(handle, text);
|
||||
out.imported = await importDrawingTable(handle, text, sheetPick);
|
||||
out.added = added;
|
||||
out.noHandle = importDrawingTable({ getDoc: () => book }, grid);
|
||||
out.noSheet = importDrawingTable(null, grid);
|
||||
out.noHandle = await importDrawingTable({ getDoc: () => book }, grid, sheetPick);
|
||||
out.noSheet = await importDrawingTable(null, grid, sheetPick);
|
||||
out.toasts = toasts;
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@@ -84,9 +87,8 @@ def test_grid_table_becomes_new_sheet(run):
|
||||
"B3": {"값": 2.5},
|
||||
}
|
||||
assert sheet["병합"] == ["B1:C1", "A2:A3"]
|
||||
# 40 mm → 21.6 글자 · 300 mm → 60(위 끝) · 10 mm → 28.3 pt · 1 mm → 12(아래 끝)
|
||||
assert sheet["열"] == {"A": {"폭": 21.6}, "B": {"폭": 16.2}, "C": {"폭": 60}}
|
||||
assert sheet["행"] == {"1": {"높이": 28.3}, "2": {"높이": 19.8}, "3": {"높이": 12}}
|
||||
# 칸 크기 · 글 크기 = 기본(105-1) — 도면 mm(폭 · 높이)을 안 옮김
|
||||
assert "열" not in sheet and "행" not in sheet
|
||||
|
||||
|
||||
def test_text_fallback_has_no_sizes(run):
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""도면 → 면적산출 넣을 자리 고르기(PLAN 105-1) — Node 로 판정 · 화면은 ORCA.
|
||||
|
||||
못박는 것
|
||||
- 받은 뒤 작은 창 [새 시트] / [고른 칸부터] · 취소면 아무것도 안 함.
|
||||
- [고른 칸부터] = 손잡이 `pasteTable`(붙여넣기 길) 한 번 · 표 → `<table>` 글(병합 = rowspan · colspan · 글 이스케이프).
|
||||
- 덮을 자리에 값 · 알약 이은 칸이 있으면 물음 · 없으면 안 물음 · 물음 [취소]면 안 붙임.
|
||||
- 덮을 자리 셈 `pasteHits`(스프레드시트 부품) — 값 · 식 칸 수 · 알약 칸 수(빈 값 · 범위 밖은 뺌).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_paste_hits_entry.ts"
|
||||
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts", "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const toasts = [];
|
||||
const asked = [];
|
||||
let answer = true;
|
||||
const stubs = {
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (m, k) => toasts.push([k, m]),
|
||||
showConfirmDialog: async (m, ok) => (asked.push([m, ok]), answer),
|
||||
},
|
||||
};
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
|
||||
const { tableToHtml, importDrawingTable } = box.exports;
|
||||
|
||||
const table = {
|
||||
kind: "grid",
|
||||
rows: [["구분", "수량", ""], ["흙<깎기>", 1500, "m3"], ["", 2.5, ""]],
|
||||
merges: [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }, { row: 1, col: 0, rowSpan: 2, colSpan: 1 }],
|
||||
colWidths: [40, 30, 300],
|
||||
rowHeights: [10, 7, 1],
|
||||
};
|
||||
const book = { 시트: [{ id: "s1", 이름: "면적" }] };
|
||||
|
||||
(async () => {
|
||||
const out = { html: tableToHtml(table) };
|
||||
const calls = [];
|
||||
let hit = { 칸: 0, 연결: 0, 범위: "C3:E5" };
|
||||
const handle = {
|
||||
getDoc: () => book,
|
||||
addSheet: (s) => calls.push(["addSheet", s.이름]),
|
||||
pasteTable: async (html, check) => {
|
||||
calls.push(["pasteTable", html === out.html]);
|
||||
return (await check(hit)) ? hit.범위 : null;
|
||||
},
|
||||
};
|
||||
const sizes = [];
|
||||
const pick = (got) => async (rows, cols) => (sizes.push([rows, cols]), got);
|
||||
out.cancel = await importDrawingTable(handle, table, pick(null));
|
||||
out.clean = await importDrawingTable(handle, table, pick("cell"));
|
||||
out.askedClean = asked.length;
|
||||
hit = { 칸: 2, 연결: 1, 범위: "C3:E5" };
|
||||
out.overwrite = await importDrawingTable(handle, table, pick("cell"));
|
||||
answer = false;
|
||||
out.refused = await importDrawingTable(handle, table, pick("cell"));
|
||||
out.sheet = await importDrawingTable(handle, table, pick("sheet"));
|
||||
out.noPaste = await importDrawingTable({ getDoc: () => book, addSheet: () => 0 }, table, pick("cell"));
|
||||
Object.assign(out, { calls, sizes, asked, toasts });
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def run():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
done = subprocess.run(
|
||||
[node, "-e", SCRIPT, str(ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_표는_html_표로(run):
|
||||
assert run["html"] == (
|
||||
"<table>"
|
||||
'<tr><td>구분</td><td colspan="2">수량</td></tr>'
|
||||
'<tr><td rowspan="2">흙<깎기></td><td>1500</td><td>m3</td></tr>'
|
||||
"<tr><td>2.5</td><td></td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
|
||||
|
||||
def test_넣을_자리_고르기(run):
|
||||
assert run["sizes"][0] == [3, 3] # 창 글 — 3행 × 3열
|
||||
assert run["cancel"] is None # 취소 = 아무것도 안 함
|
||||
assert run["clean"] == "C3:E5" and run["askedClean"] == 0 # 빈 자리 = 안 물음
|
||||
assert run["overwrite"] == "C3:E5" # 덮음 [덮어쓰기]
|
||||
assert run["refused"] is None # 덮음 [취소]
|
||||
assert run["sheet"] == "도면에서 1"
|
||||
assert run["noPaste"] is None
|
||||
assert run["calls"] == [
|
||||
["pasteTable", True],
|
||||
["pasteTable", True],
|
||||
["pasteTable", True],
|
||||
["addSheet", "도면에서 1"],
|
||||
]
|
||||
|
||||
|
||||
def test_덮으면_물음(run):
|
||||
assert len(run["asked"]) == 2
|
||||
message, ok = run["asked"][0]
|
||||
assert "C3:E5" in message and "값 있는 칸 2개" in message and "알약 이은 칸 1개" in message
|
||||
assert ok == "덮어쓰기"
|
||||
kinds = [kind for kind, _ in run["toasts"]]
|
||||
assert kinds == ["success", "success", "success", "error"]
|
||||
assert "C3:E5" in run["toasts"][0][1]
|
||||
|
||||
|
||||
def test_손잡이와_붙여넣기_길():
|
||||
sheet = (ROOT / "A00_Common/spreadsheet/spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert "pasteTable: (html, check) => pasteTableAt(ctx, html, check)," in sheet
|
||||
clip = (ROOT / "A00_Common/spreadsheet/spreadsheet_clipboard.ts").read_text(encoding="utf-8")
|
||||
body = clip.split("export async function pasteTableAt")[1].split("\n}\n")[0]
|
||||
assert "parseClipboard(html, " in body and "pasteBlock(ctx, block, at)" in body
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_덮을_자리_셈():
|
||||
with tempfile.TemporaryDirectory(prefix="spreadsheet_paste_hits_") as outdir:
|
||||
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(VITE),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
entry_rel,
|
||||
"--outDir",
|
||||
outdir,
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
|
||||
got = subprocess.run(
|
||||
["node", str(Path(outdir) / "spreadsheet_paste_hits_entry.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
assert got.stdout.strip(), got.stderr
|
||||
result = json.loads(got.stdout)
|
||||
assert result["fail"] == 0, result["failures"]
|
||||
assert result["pass"] == 4
|
||||
Reference in New Issue
Block a user