From b93e03e50548427a86354c6490403a94109aba6c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:39:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20fill.ts=EB=A5=BC=20A=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=8B=A4=20=EA=B5=AC=ED=98=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20=C2=B7=20numfmt=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=20=EC=84=9C=EC=8B=9D=20=EB=8C=80=EC=A1=B0=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fill.ts가 A의 실제 moveFormula·toA1을 쓰도록 정리(식 채우기 시험 통과). 실무 구조도 xlsx 세 벌 실측 형식 코드 66개 대조 — `_x` trailing 공백을 trim이 삼키던 버그, 글 값에 순수 수 형식이 적용되던 버그, `General"단위"` 접미 버릇, `[$통화-로캘]` 태그 미지원을 고침. 분수 형식(`0/0`)은 1단계 형식 부분집합 밖이라 남겨둠. 회귀 시험 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc --- A00_Common/spreadsheet/spreadsheet_fill.ts | 19 +--- A00_Common/spreadsheet/spreadsheet_numfmt.ts | 40 +++++-- .../spreadsheet/spreadsheet_b_test_entry.ts | 37 ++++++ .../spreadsheet_numfmt_survey_entry.ts | 43 +++++++ .../tester/test_spreadsheet_numfmt_survey.py | 105 ++++++++++++++++++ 5 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts create mode 100644 resources/tester/test_spreadsheet_numfmt_survey.py diff --git a/A00_Common/spreadsheet/spreadsheet_fill.ts b/A00_Common/spreadsheet/spreadsheet_fill.ts index 556d8033..2c33275c 100644 --- a/A00_Common/spreadsheet/spreadsheet_fill.ts +++ b/A00_Common/spreadsheet/spreadsheet_fill.ts @@ -7,22 +7,7 @@ import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types"; import { moveFormula } from "./spreadsheet_refshift"; - -/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */ -function colLetters(c: number): string { - let n = c + 1; - let s = ""; - while (n > 0) { - const rem = (n - 1) % 26; - s = String.fromCharCode(65 + rem) + s; - n = Math.floor((n - 1) / 26); - } - return s; -} - -function a1(r: number, c: number): string { - return `${colLetters(c)}${r + 1}`; -} +import { toA1 } from "./spreadsheet_address"; function detectDirection(source: CellRange, target: CellRange): FillDirection { if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down"; @@ -64,7 +49,7 @@ export function fillCells( const targetEnd = vertical ? target.r1 : target.c1; const lineLen = sourceEnd - sourceStart + 1; - const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos)); + const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos)); const sourceCells: (Cell | undefined)[] = []; for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]); diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index adde4c58..1351fcab 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -64,7 +64,12 @@ function extractColor(section: string): { rest: string; color?: string } { let color: string | undefined; const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => { const lower = inner.toLowerCase(); - if (COLOR_NAMES.includes(lower)) color = lower; + if (COLOR_NAMES.includes(lower)) { + color = lower; + return ""; + } + const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner); + if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김 return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시 }); return { rest, color }; @@ -78,6 +83,7 @@ type Tok = | { k: "point" } | { k: "percent" } | { k: "text" } + | { k: "general" } | { k: "lit"; ch: string }; function tokenize(s: string): { toks: Tok[]; fillChar?: string } { @@ -85,6 +91,12 @@ function tokenize(s: string): { toks: Tok[]; fillChar?: string } { let fillChar: string | undefined; for (let i = 0; i < s.length; i++) { const ch = s[i]; + // 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇. + if (s.slice(i, i + 7).toLowerCase() === "general") { + toks.push({ k: "general" }); + i += 6; + continue; + } if (ch === '"') { let j = i + 1; while (j < s.length && s[j] !== '"') { @@ -214,6 +226,10 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string { continue; } if (t.k === "text") continue; // 수 구역엔 의미 없음(방어) + if (t.k === "general") { + out += generalFormat(magnitude); + continue; + } out += t.ch; } return out; @@ -238,34 +254,34 @@ function chooseSection(sections: string[], value: Frac): { code: string; forceMi } function formatNumber(value: Frac, code: string | undefined): FormattedText { - const trimmed = code?.trim(); - if (!trimmed || trimmed.toLowerCase() === "general") { + if (code === undefined || code === "" || code.trim().toLowerCase() === "general") { return { 글: generalFormat(value), 정렬: "right" }; } - const sections = splitSections(trimmed); + const sections = splitSections(code); const { code: chosen, forceMinus } = chooseSection(sections, value); const { rest, color } = extractColor(chosen); const { toks, fillChar } = tokenize(rest); const extra: Partial = {}; if (color) extra.색 = color; if (fillChar) extra.채움글 = fillChar; - if (!toks.some((t) => t.k === "digit")) { + const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; + if (!toks.some((t) => t.k === "digit" || t.k === "general")) { let out = ""; for (const t of toks) if (t.k === "lit") out += t.ch; return { 글: out, 정렬: "right", ...extra }; } - const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; const body = renderNumericSection(toks, magnitude); return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; } function formatText(value: string, code: string | undefined): FormattedText { - const trimmed = code?.trim(); - if (!trimmed) return { 글: value, 정렬: "left" }; - const sections = splitSections(trimmed); - // 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로. - const textCode = - sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined; + if (code === undefined || code === "") return { 글: value, 정렬: "left" }; + const sections = splitSections(code); + // 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) · + // 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로. + let textCode: string | undefined; + if (sections.length >= 4) textCode = sections[3]; + else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0]; if (textCode === undefined) return { 글: value, 정렬: "left" }; const { rest, color } = extractColor(textCode); const { toks, fillChar } = tokenize(rest); diff --git a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts index 7f78fccb..92972b24 100644 --- a/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_b_test_entry.ts @@ -216,6 +216,34 @@ ok("0.00", fmt(frac(BigInt(150), 100n), "0.00") === "1.50", fmt(frac(150n, 100n) ok("#,##0", fmt(frac(1234567n), "#,##0") === "1,234,567", fmt(frac(1234567n), "#,##0")); ok("천 나눔 끝쉼표", fmt(frac(1234000n), "#,##0,") === "1,234", fmt(frac(1234000n), "#,##0,")); ok("퍼센트", fmt(frac(50n, 100n), "0%") === "50%", fmt(frac(50n, 100n), "0%")); + +// 실무 구조도 xlsx 세 벌 실측 코드(2026-09-27 대조) — 회귀 방지. +ok( + "_x 폭띄움 끝쉼표", + fmt(frac(3n), "0.00_ ") === "3.00 ", + JSON.stringify(fmt(frac(3n), "0.00_ ")), +); +ok( + "_x 글에는 안 먹음(순수 수 형식)", + fmt("외부벽체", "0.00_ ") === "외부벽체", + fmt("외부벽체", "0.00_ "), +); +ok( + "General 단위접미(실무 버릇)", + fmt(frac(4n), 'General"열"') === "4열", + fmt(frac(4n), 'General"열"'), +); +ok( + "회계형(끝 구역 글)", + fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-') === " 1.04 ", + fmt(frac(1044n, 1000n), '_-* #,##0.00_-;\\-* #,##0.00_-;_-* "-"_-;_-@_-'), +); +ok( + "통화 태그([$기호-로캘])", + fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-') === " €2.00 ", + fmt(frac(2n), '_-[$€-2]* #,##0.00_-;-[$€-2]* #,##0.00_-;_-[$€-2]* "-"??_-'), +); +ok("따옴표 접두·접미", fmt(frac(800n), '"φ"###') === "φ800", fmt(frac(800n), '"φ"###')); ok( "음수 회계형", fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)", @@ -278,6 +306,15 @@ function bookWith(cells: Record): Workbook { JSON.stringify(out), ); } +{ + const book = bookWith({ B1: { 식: "A1*2" } }); + const out = fillCells(book, "s1", { r0: 0, c0: 1, r1: 0, c1: 1 }, { r0: 0, c0: 1, r1: 2, c1: 1 }); + ok( + "채우기 식 상대 이동(A moveFormula)", + out.B2?.식 === "A2*2" && out.B3?.식 === "A3*2", + JSON.stringify(out), + ); +} // ── 요약 ──────────────────────────────────────────────────────────────── diff --git a/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts b/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts new file mode 100644 index 00000000..3ade8659 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_numfmt_survey_entry.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * spreadsheet_numfmt_survey_entry.ts (B 자기 시험) + * 실무 구조도 xlsx 세 벌에서 뽑은 실제 숫자 형식 코드 전부를 numfmt 로 돌려 사람이 대조. + * 입력 = 시험이 openpyxl 로 미리 뽑은 JSON(코드 · 실측 수 · 실측 글 견본). + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; +import { formatValue } from "../../../A00_Common/spreadsheet/spreadsheet_numfmt"; + +interface CodeSample { + code: string; + count: number; + num: number | null; + str: string | null; +} + +const inputPath = process.argv[2]; +const samples: CodeSample[] = JSON.parse(readFileSync(inputPath, "utf-8")); + +const rows = samples.map((s) => { + let numOut: string | null = null; + let numErr: string | null = null; + if (s.num !== null) { + try { + numOut = formatValue(toFrac(s.num), s.code).글; + } catch (e) { + numErr = String(e); + } + } + let strOut: string | null = null; + let strErr: string | null = null; + if (s.str !== null) { + try { + strOut = formatValue(s.str, s.code).글; + } catch (e) { + strErr = String(e); + } + } + return { ...s, numOut, numErr, strOut, strErr }; +}); + +console.log(JSON.stringify(rows, null, 1)); diff --git a/resources/tester/test_spreadsheet_numfmt_survey.py b/resources/tester/test_spreadsheet_numfmt_survey.py new file mode 100644 index 00000000..62e33771 --- /dev/null +++ b/resources/tester/test_spreadsheet_numfmt_survey.py @@ -0,0 +1,105 @@ +"""numfmt — 실무 구조도 xlsx 세 벌 실측 숫자 형식 코드 전부를 돌려 안 죽는지 대조(2026-09-27). + +`resources/knowledge/original/실무문서/` 세 벌(openpyxl 로 읽기만 · 안 고침)에서 쓰인 서로 다른 +숫자 형식 코드를 모아 `spreadsheet_numfmt_survey_entry.ts`(vite ssr → node)로 돌린다. +값별 정답은 실제 엑셀이 있어야 재는데(이 환경엔 없음) `resources/tester/spreadsheet/ +spreadsheet_b_test_entry.ts` 에 실측 코드 몇 개를 손으로 대조한 회귀 시험을 이미 넣었다 — +여기는 "실무에 쓰인 코드 전부가 죽지 않고 뭔가 보이는 글을 냄"만 잰다(폭넓은 그물). + +알려진 빈 자리(1단계 밖 · `9_엑셀검토.md` 형식 부분집합에 없음): +- 분수 형식(`0/0`, `# ?/?` 류) — 미지원 · 자리표시만 있고 값은 틀리게 나옴. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_numfmt_survey_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + +FILES = [ + ROOT + / "resources/knowledge/original/실무문서/25년 산불진화임도(기번8)(울진소광)/1. 설계원본/02-수량/07-구조도-소광리.xlsx", + ROOT + / "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/02 산출자료/5. 구조도(기번3).xlsx", + ROOT + / "resources/knowledge/original/실무문서/20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/02.엑셀자료/구조도/구조도(변경).xlsx", +] + +# 미지원으로 이미 아는 코드(1단계 밖 · 분수 형식) — 죽지 않아도 값이 틀리므로 미리 뺌. +KNOWN_UNSUPPORTED = {"0/0"} + +pytestmark = pytest.mark.skipif( + not all(f.is_file() for f in FILES) or shutil.which("node") is None, + reason="실무 xlsx 세 벌 또는 node 가 없음", +) + + +def _collect_codes() -> list[dict]: + codes: dict[str, dict] = {} + for f in FILES: + wb = openpyxl.load_workbook(f, data_only=True, read_only=True) + for ws in wb.worksheets: + for row in ws.iter_rows(): + for cell in row: + if cell.value is None or isinstance(cell.value, bool): + continue + fmt = cell.number_format + if fmt == "General": + continue + info = codes.setdefault(fmt, {"count": 0, "num": None, "str": None}) + info["count"] += 1 + if isinstance(cell.value, (int, float)) and info["num"] is None: + info["num"] = cell.value + if isinstance(cell.value, str) and info["str"] is None: + info["str"] = cell.value + wb.close() + return [{"code": k, **v} for k, v in codes.items()] + + +def _run_survey(samples: list[dict]) -> list[dict]: + with tempfile.TemporaryDirectory(prefix="numfmt_survey_") 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}" + bundle = Path(outdir) / "spreadsheet_numfmt_survey_entry.js" + input_path = Path(outdir) / "codes.json" + input_path.write_text(json.dumps(samples, ensure_ascii=False), encoding="utf-8") + run = subprocess.run( + ["node", str(bundle), str(input_path)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_real_world_format_codes_do_not_error(): + samples = _collect_codes() + assert len(samples) > 30, f"실무 코드가 너무 적게 걸림: {len(samples)}" + results = _run_survey(samples) + broken = [ + r + for r in results + if r["code"] not in KNOWN_UNSUPPORTED and (r["numErr"] or r["strErr"]) + ] + assert broken == [], json.dumps(broken, ensure_ascii=False, indent=1)