feat(E05): 안정성평가 보고서 양식(시트 여섯) · 자리표 · 시험 (534-3 양식 부분)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# E05 보고서 양식 자리표 — `stab_report.json`
|
||||
|
||||
빌더(534-3 뒤쪽)가 읽는 표. 시트 id · 칸 주소 · 서식 번호는 양식 그대로 쓰고 바꾸지 않음.
|
||||
|
||||
## 공통
|
||||
|
||||
- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀.
|
||||
- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00).
|
||||
- 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용.
|
||||
- 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림.
|
||||
- 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지).
|
||||
- 그림 열쇠 = `fig/<측점 키>.png`(측점 키 = `common_util_station_key`) · 3D 띠 = `fig/3d_band.png`. 양식의 `fig/{측점키}.png` 는 자리 표시일 뿐 — 빌더가 목록을 새로 씀.
|
||||
|
||||
## s1 표지 (사용자 몫)
|
||||
|
||||
| 칸 | 내용 |
|
||||
| --- | --- |
|
||||
| B4:E4 | 제목(고정 글 · 서식 1) |
|
||||
| C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 |
|
||||
| B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) |
|
||||
|
||||
## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음)
|
||||
|
||||
- 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글.
|
||||
- 수식 칸: `B5` 대상 측점 수 · `B6` 연장 · `B10:B12` 조건별 최소 Fs · `C10:C12` = `입력조건!B12:B14` · `D10:D12` 판정 · `E10`·`E11`·`E12` 최소 측점 · `B15` N.G 수 · `B16` 종합 판정 · `B17`(병합 B17:F19) 판정 글.
|
||||
- 지진을 안 쓰면 `측점표!F` 열을 비움 → 개요 지진 행은 `-`.
|
||||
|
||||
## s3 입력조건 (프로그램 몫)
|
||||
|
||||
| 구역 | 칸 | 내용 |
|
||||
| --- | --- | --- |
|
||||
| 해석 방법 | `B5` `B6` `B7` `B8` (각 B:G 병합) | 해석 기법 · 활동면 탐색 · 3D 해석 · 적용 기준 글 |
|
||||
| 기준 안전율 | `B12` `B13` `B14` | 건기 · 우기 · 지진 기준 값(수 · 서식 8) · 지진 끔 = 글 `-` |
|
||||
| | `C12` `C13` `C14` (C:G 병합) | 비고 글 |
|
||||
| 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 |
|
||||
| 노면 하중 | `B22` | 수(kPa · 서식 16) |
|
||||
| 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 |
|
||||
|
||||
- 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간).
|
||||
|
||||
## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
|
||||
|
||||
- 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로.
|
||||
|
||||
| 열 | 내용 | 서식 |
|
||||
| --- | --- | --- |
|
||||
| A | 측점 이름 | 7 |
|
||||
| B | 누가거리(m · 수) | 17 |
|
||||
| C | 구분(`깎기` · `쌓기`) | 7 |
|
||||
| D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 |
|
||||
| G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 |
|
||||
| J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 |
|
||||
|
||||
- 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음.
|
||||
- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀).
|
||||
|
||||
## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽)
|
||||
|
||||
덩이 `k`(0 부터)의 시작 오프셋 `dr = 28 × k` — 양식의 1~28 행 덩이를 `dr` 만큼 아래로 복사해 단면 수만큼 만듦(칸 · 병합 · 행 높이 · `조건부서식` 범위 포함).
|
||||
|
||||
| 덩이 안 행 | 칸 | 내용 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) |
|
||||
| 2 | `B2:H2` 병합 | 선정 사유 글 |
|
||||
| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
|
||||
| 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y |
|
||||
| 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) |
|
||||
| 27 | `B27:H27` 병합 | 비고 글 |
|
||||
| 28 | 빈 줄 | 쪽 끝 |
|
||||
|
||||
- `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김).
|
||||
- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움.
|
||||
|
||||
## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복)
|
||||
|
||||
| 칸 | 내용 |
|
||||
| --- | --- |
|
||||
| 그림 `A5:H16` | 평면 위험 띠 — 그림 칸 약 684 × 320 px(2.1 : 1) · 열쇠 `fig/3d_band.png` |
|
||||
| 20 행부터 구간 하나 = 한 줄 | A 구간 · B 시작 측점 · C 끝 측점(서식 7) · D 3D 최소 Fs · E 2D 최소 Fs(서식 8) · **F 3D/2D 비 = 수식 `IF(E{r}>0,D{r}/E{r},"")`**(서식 8) · G 임계 체적 m³(서식 14) · H 판정(서식 7 → N.G 10 · O.K 11) |
|
||||
|
||||
- 20 행 서식을 줄마다 복사.
|
||||
@@ -0,0 +1,252 @@
|
||||
"""534-3(양식) — E05 안정성평가 보고서 양식 `resources/master_template/report/stab_report.json`.
|
||||
|
||||
계약 검사(키 · 서식 번호 · A1 · 병합 · 쪽 나눔) + 가짜 값 사본으로 수식 · 인쇄 HTML · PDF 쪽 수 · xlsx 되읽기.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_print_pdf import PrintError
|
||||
from common_util.common_util_svg_png import svgs_to_pngs
|
||||
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FORM = ROOT / "resources/master_template/report/stab_report.json"
|
||||
BLOCK = 28 # 대표단면 한 쪽 = 28 행
|
||||
SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="712" height="533">'
|
||||
'<rect width="712" height="533" fill="#cde"/></svg>'
|
||||
)
|
||||
BOOK_KEYS = {"종류", "판", "열", "기본", "서식", "시트", "활성", "계산값"}
|
||||
SHEET_KEYS = {
|
||||
"id", "이름", "칸", "병합", "열", "행", "틀고정", "보기", "기본", "comments", "필터",
|
||||
"조건부서식", "유효성", "이름정의", "숨김", "탭색", "연결", "인쇄", "그림",
|
||||
} # fmt: skip
|
||||
STYLE_KEYS = {
|
||||
"글꼴", "크기", "굵게", "기울임", "밑줄", "취소선", "글자색", "채움", "가로", "세로", "줄바꿈",
|
||||
"테두리", "형식",
|
||||
} # fmt: skip
|
||||
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
|
||||
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
|
||||
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"]
|
||||
|
||||
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _form() -> dict:
|
||||
return json.loads(FORM.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rc(a1: str) -> tuple[int, int]:
|
||||
m = A1.match(a1)
|
||||
assert m, a1
|
||||
col = 0
|
||||
for ch in m.group(1):
|
||||
col = col * 26 + ord(ch) - 64
|
||||
return int(m.group(2)), col
|
||||
|
||||
|
||||
def _span(text: str) -> tuple[int, int, int, int]:
|
||||
a, _, b = text.partition(":")
|
||||
(r0, c0), (r1, c1) = _rc(a), _rc(b or a)
|
||||
assert r0 <= r1 and c0 <= c1, text
|
||||
return r0, c0, r1, c1
|
||||
|
||||
|
||||
def _contract(book: dict) -> None:
|
||||
assert set(book) <= BOOK_KEYS and book["종류"] == "통합문서" and book["판"] == 1
|
||||
assert book["서식"][0] == {}
|
||||
for st in book["서식"]:
|
||||
assert set(st) <= STYLE_KEYS
|
||||
ids = [s["id"] for s in book["시트"]]
|
||||
assert len(set(ids)) == len(ids) and book["활성"] in ids
|
||||
for sheet in book["시트"]:
|
||||
assert set(sheet) <= SHEET_KEYS
|
||||
for a1, cell in sheet["칸"].items():
|
||||
_rc(a1)
|
||||
assert set(cell) <= {"값", "식", "서식"} and not ({"값", "식"} <= set(cell))
|
||||
assert 0 <= cell.get("서식", 0) < len(book["서식"])
|
||||
used: set[tuple[int, int]] = set()
|
||||
for m in sheet.get("병합", []):
|
||||
r0, c0, r1, c1 = _span(m)
|
||||
box = {(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)}
|
||||
assert len(box) > 1 and not (used & box), m
|
||||
used |= box
|
||||
pr = sheet.get("인쇄", {})
|
||||
assert set(pr) <= PRINT_KEYS
|
||||
assert pr.get("쪽나눔", []) == sorted(set(pr.get("쪽나눔", [])))
|
||||
if "머리행" in pr:
|
||||
assert 1 <= pr["머리행"][0] <= pr["머리행"][1]
|
||||
for fig in sheet.get("그림", []):
|
||||
_span(fig["닻"])
|
||||
assert fig["열쇠"]
|
||||
for rule in sheet.get("조건부서식", []):
|
||||
assert rule["조건"]["종류"] == "글포함" and rule["범위"]
|
||||
|
||||
|
||||
# ── 가짜 값 사본 ─────────────────────────────────────────────────────────────
|
||||
def _shift(a1: str, dr: int) -> str:
|
||||
m = A1.match(a1)
|
||||
return f"{m.group(1)}{int(m.group(2)) + dr}"
|
||||
|
||||
|
||||
def _fill(book: dict, stations: int, sections: int) -> tuple[dict, int]:
|
||||
"""측점표 `stations` 줄 · 대표단면 `sections` 쪽 · 3D 두 줄을 채운 사본 · N.G 수."""
|
||||
book = copy.deepcopy(book)
|
||||
by = {s["이름"]: s for s in book["시트"]}
|
||||
s3, s4, s5, s6 = by["입력조건"], by["측점표"], by["대표단면"], by["3D요약"]
|
||||
for r, v in ((12, 1.5), (13, 1.2), (14, 1.1)):
|
||||
s3["칸"][f"B{r}"]["값"] = v
|
||||
ng = 0
|
||||
for i in range(stations):
|
||||
r = 6 + i
|
||||
bad = i % 7 == 3
|
||||
ng += bad
|
||||
fs = [1.32 if bad else 1.8 + i / 100, 1.5 + i / 100, 1.3 + i / 100]
|
||||
for letter, v, st in (
|
||||
("A", f"No.{i}", 7), ("B", 20.0 * i, 17), ("C", "깎기" if i % 2 else "쌓기", 7),
|
||||
("D", fs[0], 8), ("E", fs[1], 8), ("F", fs[2], 8),
|
||||
("G", 1.5, 8), ("H", 1.2, 8), ("I", 1.1, 8), ("J", "N.G" if bad else "O.K", 7),
|
||||
): # fmt: skip
|
||||
s4["칸"][f"{letter}{r}"] = {"값": v, "서식": st}
|
||||
for k in range(1, sections): # 첫 덩이를 아래로 복사
|
||||
dr = BLOCK * k
|
||||
for a1, cell in list(s5["칸"].items()):
|
||||
if int(A1.match(a1).group(2)) <= BLOCK:
|
||||
s5["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
|
||||
for m in list(s5["병합"]):
|
||||
if int(re.search(r"\d+", m).group()) <= BLOCK:
|
||||
s5["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
|
||||
for r, info in list(s5["행"].items()):
|
||||
if int(r) <= BLOCK:
|
||||
s5["행"][str(int(r) + dr)] = dict(info)
|
||||
a, _, b = s5["그림"][0]["닻"].partition(":")
|
||||
s5["그림"].append({"열쇠": f"fig/s{k}.png", "닻": f"{_shift(a, dr)}:{_shift(b, dr)}"})
|
||||
s5["인쇄"]["쪽나눔"].append(BLOCK * (k + 1))
|
||||
s5["그림"][0]["열쇠"] = "fig/s0.png"
|
||||
for r, row in (
|
||||
(20, ("1구간", "No.0", "No.9", 1.9, 1.7, 1200)),
|
||||
(21, ("2구간", "No.9", "No.19", 1.4, 1.5, 800)),
|
||||
):
|
||||
for letter, v in zip("ABCDEG", row):
|
||||
s6["칸"][f"{letter}{r}"] = {"값": v, "서식": s6["칸"][f"{letter}20"]["서식"]}
|
||||
s6["칸"][f"F{r}"] = {"식": f'IF(E{r}>0,D{r}/E{r},"")', "서식": s6["칸"]["F20"]["서식"]}
|
||||
s6["칸"][f"H{r}"] = {"값": "O.K" if r == 20 else "N.G", "서식": s6["칸"]["H20"]["서식"]}
|
||||
return book, ng
|
||||
|
||||
|
||||
# ── 시험 ─────────────────────────────────────────────────────────────────────
|
||||
def test_양식_계약():
|
||||
book = _form()
|
||||
_contract(book)
|
||||
assert [s["이름"] for s in book["시트"]] == NAMES
|
||||
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6"]
|
||||
|
||||
|
||||
def test_양식_인쇄_쪽나눔_머리행_그림():
|
||||
by = {s["이름"]: s for s in _form()["시트"]}
|
||||
for sheet in by.values():
|
||||
pr = sheet["인쇄"]
|
||||
assert pr["용지"] == "A4" and pr["맞춤"]["폭"] == 1 and pr["여백"]
|
||||
assert by["측점표"]["인쇄"]["방향"] == "landscape" and by["측점표"]["인쇄"]["머리행"] == [4, 5]
|
||||
assert by["대표단면"]["인쇄"]["쪽나눔"] == [BLOCK]
|
||||
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
|
||||
assert figs == {"표지": 0, "개요": 0, "입력조건": 0, "측점표": 0, "대표단면": 1, "3D요약": 1}
|
||||
# N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀)
|
||||
assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9
|
||||
assert any(st.get("글자색") == "#C00000" and st.get("채움") for st in _form()["서식"])
|
||||
# 안전율 칸 숫자 형식 0.00
|
||||
assert _form()["서식"][by["측점표"]["칸"]["D6"]["서식"]]["형식"] == "0.00"
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_빈_양식도_풀림():
|
||||
book = _form()
|
||||
vals = spreadsheet.recalc_workbooks([book])[0]
|
||||
assert vals["s2"]["B5"] == {"수": "0"}
|
||||
assert vals["s2"]["B10"] == {"글": "-"}
|
||||
assert vals["s2"]["B16"] == {"글": "-"}
|
||||
assert vals["s2"]["B17"] == {"글": "해석 결과 없음"}
|
||||
assert not [v for s in vals.values() for v in s.values() if "오류" in v]
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_채운_양식_수식():
|
||||
book, ng = _fill(_form(), 40, 2)
|
||||
s2 = spreadsheet.recalc_workbooks([book])[0]["s2"]
|
||||
assert s2["B5"] == {"수": "40"} and s2["B6"] == {"수": "780"}
|
||||
assert s2["B15"] == {"수": str(ng)} and ng == 6
|
||||
assert (
|
||||
s2["B10"] == {"수": "1.32"} and s2["D10"] == {"글": "N.G"} and s2["E10"] == {"글": "No.3"}
|
||||
)
|
||||
assert s2["D11"] == {"글": "O.K"} and s2["B11"] == {"수": "1.5"}
|
||||
assert s2["B16"] == {"글": "N.G"}
|
||||
assert s2["B17"]["글"].startswith("대상 측점 40개소 중 6개소가")
|
||||
s6 = spreadsheet.recalc_workbooks([book])[0]["s6"]
|
||||
assert s6["F21"] == {"수": "0.933333333333333"} or s6["F21"]["수"].startswith("0.93333")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def png() -> bytes:
|
||||
try:
|
||||
return svgs_to_pngs([SVG])[0]
|
||||
except PrintError as error:
|
||||
pytest.skip(f"브라우저 없음 — {error}")
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_견본_pdf_쪽수_머리행(png):
|
||||
book, _ = _fill(_form(), 40, 2)
|
||||
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
|
||||
try:
|
||||
pdf = spreadsheet.workbook_pdf(book, figs)
|
||||
except PrintError as error:
|
||||
pytest.skip(f"브라우저 없음 — {error}")
|
||||
from pypdf import PdfReader
|
||||
|
||||
pages = PdfReader(io.BytesIO(pdf)).pages
|
||||
texts = [p.extract_text() for p in pages]
|
||||
stab = [i for i, t in enumerate(texts) if "측점별 안전율" in t or "산정 안전율" in t]
|
||||
assert len(stab) >= 2 and all("산정 안전율" in texts[i] for i in stab) # 머리 줄 쪽마다
|
||||
assert (
|
||||
texts[0].count("비탈면 안정성 검토 보고서") == 1
|
||||
and "문헌 추정치 · 현장 시험 없음" in texts[0]
|
||||
)
|
||||
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
|
||||
assert len(sections) == 2 # 단면마다 한 쪽
|
||||
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1
|
||||
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1
|
||||
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
|
||||
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
|
||||
assert html.count("<img ") == 3 # 단면 2 + 3D 띠 1
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_견본_xlsx_되읽기(png):
|
||||
book, _ = _fill(_form(), 40, 2)
|
||||
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
|
||||
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
|
||||
assert wb.sheetnames == NAMES
|
||||
ws = wb["측점표"]
|
||||
assert ws.print_title_rows == "$4:$5" and ws.page_setup.orientation == "landscape"
|
||||
assert ws["D6"].number_format == "0.00" and ws["J6"].value in ("O.K", "N.G")
|
||||
assert "A4:A5" in {str(m) for m in ws.merged_cells.ranges}
|
||||
sec = wb["대표단면"]
|
||||
assert [b.id for b in sec.row_breaks.brk] == [BLOCK, BLOCK * 2] and len(sec._images) == 2
|
||||
assert len(wb["3D요약"]._images) == 1 and wb["3D요약"].print_title_rows == "$19:$19"
|
||||
assert wb["개요"]["B5"].value.startswith("=COUNTA(")
|
||||
assert wb["표지"]["B14"].value == "문헌 추정치 · 현장 시험 없음"
|
||||
|
||||
|
||||
def test_자리표_시트_모두_적음():
|
||||
slots = (FORM.parent / "stab_report_slots.md").read_text(encoding="utf-8")
|
||||
for sheet in _form()["시트"]:
|
||||
assert f"## {sheet['id']} {sheet['이름']}" in slots
|
||||
Reference in New Issue
Block a user