diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 403d9ac4a..40ab62717 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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; 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(); diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index d0d7796e6..1eef6e5c0 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -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; + +/** HTML 표를 지금 시트 활성 칸(왼위)부터 — 붙여넣기와 같은 길 · 표 안 빈 칸은 비움(덮어쓰기) · + * `check` 가 false 면 안 붙임 · 붙인 범위 A1 · 못 하면 null */ +export async function pasteTableAt( + ctx: SpreadsheetContext, + html: string, + check?: PasteCheck, +): Promise { + 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 칸이 범위 안인가 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts index 2c5c3881d..be52a22c9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts @@ -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): string { return `s${n}`; } -/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */ +/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것 · 칸 크기 · 글 크기는 기본) */ export function tableToSheet(table: DrawingTable, book: Pick): Sheet { const 칸: Record = {}; table.rows.forEach((line, r) => @@ -73,39 +77,123 @@ export function tableToSheet(table: DrawingTable, book: Pick .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, ">"); + +/** 표 자료 → `` 글(병합 = rowspan · colspan) — 손잡이 `pasteTable` 이 붙여넣기 길로 읽음 */ +export function tableToHtml(table: DrawingTable): string { + const cols = Math.max(0, ...table.rows.map((line) => line.length)); + const span = new Map(); + const covered = new Set(); + 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 += `${escape(line[c] ?? "")}`; + } + return `${tds}`; + }); + return `
${rows.join("")}
`; +} + +export type Where = "sheet" | "cell" | null; + +/** 넣을 자리 작은 창 — [취소] [고른 칸부터] [새 시트](주 단추 · 기본) · Esc = 취소 */ +export function askWhere(rows: number, cols: number): Promise { + 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; } -/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */ -export function importDrawingTable( +/** 받은 표 → 넣을 자리 물음 → 새 시트(활성) 또는 지금 시트 고른 칸부터 · 알림 — + * 옮긴 시트 이름 · 붙인 범위 · 못 하거나 그만두면 null */ +export async function importDrawingTable( handle: AddSheetHandle | null, table: DrawingTable, -): string | null { + choose: (rows: number, cols: number) => Promise = askWhere, +): Promise { 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; } diff --git a/resources/tester/spreadsheet/spreadsheet_paste_hits_entry.ts b/resources/tester/spreadsheet/spreadsheet_paste_hits_entry.ts new file mode 100644 index 000000000..c11e9d898 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_paste_hits_entry.ts @@ -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 })); diff --git a/resources/tester/test_m02_area_from_drawing.py b/resources/tester/test_m02_area_from_drawing.py index 226c1edeb..a406723b6 100644 --- a/resources/tester/test_m02_area_from_drawing.py +++ b/resources/tester/test_m02_area_from_drawing.py @@ -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): diff --git a/resources/tester/test_m02_area_from_drawing_place.py b/resources/tester/test_m02_area_from_drawing_place.py new file mode 100644 index 000000000..341a2eab7 --- /dev/null +++ b/resources/tester/test_m02_area_from_drawing_place.py @@ -0,0 +1,173 @@ +"""도면 → 면적산출 넣을 자리 고르기(PLAN 105-1) — Node 로 판정 · 화면은 ORCA. + +못박는 것 + - 받은 뒤 작은 창 [새 시트] / [고른 칸부터] · 취소면 아무것도 안 함. + - [고른 칸부터] = 손잡이 `pasteTable`(붙여넣기 길) 한 번 · 표 → `` 글(병합 = 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"] == ( + "
" + '' + '' + "" + "
구분수량
흙<깎기>1500m3
2.5
" + ) + + +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