Merge remote-tracking branch 'origin/dev' into sub_laptop_8

This commit is contained in:
2026-09-27 20:54:31 +09:00
65 changed files with 906 additions and 92 deletions
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-25",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-33",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-23",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-22",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-12",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-13",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-35",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-24",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-36",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-14",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-16",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-15",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-30",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-29",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-31",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-47",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-45",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-37",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-43",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-42",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-38",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-39",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-46",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-44",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-41",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-40",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-48",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-49",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-32",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-20",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-01",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-18",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-17",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-11",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-10",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-09",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-34",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-07",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-04",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-08",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-05",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -6,6 +6,14 @@
"도번": "구-06",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-26",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-03",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-19",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-21",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-02",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-28",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
+9 -1
View File
@@ -6,6 +6,14 @@
"도번": "구-27",
"도면": {
"format": 6,
"entities": []
"entities": [],
"layers": [
{
"id": "기본",
"name": "기본",
"isVisible": true,
"isLocked": false
}
]
}
}
@@ -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<string, Cell>): 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),
);
}
// ── 요약 ────────────────────────────────────────────────────────────────
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>C 격자 시험 틀</title>
<style>
html,
body {
margin: 0;
height: 100%;
}
#app {
position: fixed;
inset: 0;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./spreadsheet_grid_test.ts"></script>
</body>
</html>
@@ -0,0 +1,161 @@
/* =============================================================================
* spreadsheet_grid_test.ts
* C(격자) 화면 시험 틀 — 병합 · 틀고정 · 숨김 · 폭 · hair 선 · 균등 분할 · 선택 영역 가운데 ·
* [Red] 음수 서식 · 1만 행 스크롤 때 DOM 칸 수를 ORCA 로 확인. D · 엔진이 없으니 이 파일이
* `SpreadsheetContext` 를 최소로 흉내 낸다(엔진 값은 안 씀 — 식 칸이 없음).
* ========================================================================== */
import { createGrid } from "../../../A00_Common/spreadsheet/spreadsheet_grid";
import type {
CalcEngine,
Command,
History,
Sheet,
Workbook,
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
import { colIndex } from "../../../A00_Common/spreadsheet/spreadsheet_address";
import type {
GridHandle,
SpreadsheetContext,
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
const sheet: Sheet = {
id: "s1",
이름: "시험",
칸: {
A1: { 값: "□격자 시험 — 병합 · 틀고정 · 숨김 · hair 선", 서식: 1 },
A3: { 값: "공종", 서식: 2 },
B3: { 값: "규격", 서식: 2 },
C3: { 값: "산출 내역", 서식: 2 },
G3: { 값: "단위", 서식: 2 },
H3: { 값: "수량", 서식: 2 },
A4: { 값: "터파기", 서식: 3 },
C4: { 값: "균등 분할 정렬 보기 글", 서식: 4 },
H4: { 값: -12.5, 서식: 6 },
A6: { 값: "선택 영역 가운데(centerContinuous) 보기", 서식: 5 },
A8: { 값: "hair 테두리 칸", 서식: 3 },
B8: { 값: 3.14159, 서식: 7 },
J10000: { 값: "맨 끝 칸(1만 행 스크롤 확인용)" },
},
comments: { H4: "메모 — ROUNDDOWN 끝수 확인" },
병합: ["A1:H1", "B8:C9"],
열: { A: { 폭: 14 }, D: { 숨김: true } },
행: { "1": { 높이: 22 }, "5": { 숨김: true } },
틀고정: { 행: 3, 열: 1 },
보기: { 눈금선: true },
};
const book: Workbook = {
종류: "통합문서",
판: 1,
열: "test",
기본: { 글꼴: "굴림", 크기: 10, 열폭: 8.43, 행높이: 15 },
서식: [
{},
{ 크기: 14, 굵게: true },
{
가로: "center",
세로: "center",
채움: "#F2F2F2",
테두리: {
위: { 선: "thin" },
아래: { 선: "thin" },
왼: { 선: "thin" },
오른: { 선: "thin" },
},
},
{ 세로: "center", 테두리: { 위: { 선: "hair" }, 아래: { 선: "hair" } } },
{ 가로: "distributed", 세로: "center" },
{ 가로: "centerContinuous", 세로: "center" },
{ 형식: "0.00;[Red]-0.00" },
{
형식: "0.0000",
테두리: {
위: { 선: "hair" },
아래: { 선: "hair" },
왼: { 선: "hair" },
오른: { 선: "hair" },
},
},
],
시트: [sheet],
활성: "s1",
};
function noopEngine(): CalcEngine {
return {
update: () => [],
rebuild: () => {},
value: () => null,
precedents: () => [],
snapshot: () => ({}),
};
}
function noopHistory(): History {
return {
push: () => {},
undo: () => null,
redo: () => null,
canUndo: () => false,
canRedo: () => false,
clear: () => {},
};
}
let grid!: GridHandle;
const ctx: SpreadsheetContext = {
root: document.getElementById("app")!,
book,
readOnly: false,
engine: noopEngine(),
history: noopHistory(),
selection: {
시트: "s1",
범위: [{ r0: 2, c0: 2, r1: 2, c1: 2 }],
활성: { r: 2, c: 2 },
기준: { r: 2, c: 2 },
},
sheet: () => sheet,
dispatch: (command: Command) => {
if (command.종류 === "열폭") {
const c0 = command.열[0];
const key = c0 >= 0 ? String.fromCharCode(65 + c0) : "A"; // 시험 데이터가 A~J 안쪽이라 단순 변환으로 충분
sheet.열 = { ...sheet.열, [key]: { ...sheet.열?.[key], 폭: command.폭 ?? undefined } };
} else if (command.종류 === "행높이") {
const r0 = command.행[0];
sheet.행 = {
...sheet.행,
[String(r0 + 1)]: { ...sheet.행?.[String(r0 + 1)], 높이: command.높이 ?? undefined },
};
}
grid.render();
},
undo: () => {},
redo: () => {},
select: () => grid.renderSelection(),
showSheet: () => {},
get grid() {
return grid;
},
editor: {
editing: () => false,
begin: () => {},
commit: () => {},
cancel: () => {},
sync: () => {},
},
} as SpreadsheetContext;
grid = createGrid(ctx);
ctx.root.append(grid.root);
// ORCA 수치 확인용 — 눈에 안 보이는 전역에 걸어 둠(콘솔 · eval 로 셈).
(window as unknown as { __gridTest: unknown }).__gridTest = {
grid,
sheet,
book,
colIndex,
countCells: () => grid.root.querySelectorAll(".aislo-cell").length,
};
@@ -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));
@@ -60,7 +60,10 @@ def test_M02_만_늦게_불러옴():
check=True,
).stdout.splitlines()
for rel in listed:
if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file():
# 시험용 진입(`resources/tester/`)은 앱 번들 밖
if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")):
continue
if not (ROOT / rel).is_file():
continue
text = (ROOT / rel).read_text(encoding="utf-8", errors="replace")
if "spreadsheet/spreadsheet" not in text:
+4 -2
View File
@@ -35,8 +35,10 @@ def test_새로_도번_자동_409_없는_열(client):
"pp_len",
"구-01",
)
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역
assert doc["도면"] == {"format": 6, "entities": []}
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶)
assert doc["도면"]["format"] == 6
assert doc["도면"]["entities"] == []
assert len(doc["도면"]["layers"]) == 1
# 산출근거는 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서
assert "산출근거" not in doc
assert client.get("/api/m02/templates/basis/pp_len").json()["문서"]["종류"] == "통합문서"
@@ -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)