From 9d1a4f9bdb858328f2059b7d43d3efc0db35a7fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 11:32:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=97=B4=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EB=A9=B4=20=EC=A4=84=20=EB=8F=84=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집계표(시스템 층) 도면 줄에 `구-0N` 도번 — `fetchStructureNumbers` 를 `drawingLabel` 로 - 칸 고르기(`onSelect`)마다 표 밖 형제 자리에 컨테이너 — 열 머리·도번 · 도면 미리보기(읽기만) · [도면 수정] - [도면 수정] = 고침 확인 뒤 없으면 `createStructure` · 같은 페이지에서 그 구조물 도면 CAD 편집 · 좌측 고름 표시 - 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 그대로 - 컨테이너 아래 상세 산출근거 표(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)) · 줄 더하기 · 저장 - 새 시험 `test_m02_structure_panel.py` — 집계표 이름 대조 · 창끼리 이름 계약 · `columnHead` Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TamXZKonwSwyGT74XwwCbC --- .../M02_MasterTemplete_Structure.css | 66 +++++ .../M02_MasterTemplete_Structure.ts | 257 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 81 +++++- resources/tester/test_m02_structure_panel.py | 93 +++++++ 4 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Structure.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Structure.ts create mode 100644 resources/tester/test_m02_structure_panel.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css new file mode 100644 index 00000000..b75bcb29 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -0,0 +1,66 @@ +/* M02 구조물집계표 아래 설계 컨테이너 — 머리 줄 · 도면 미리보기 · 상세 산출근거 표. */ + +/* 표 밖 형제 자리 — 비어 있을 때 메인 간격이 벌어지지 않게 칸을 지움. */ +.m02-main__extra { + display: contents; +} + +.m02-struct { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-12); + border-top: 1px solid var(--color-border); + min-width: 0; +} + +.m02-struct__head, +.m02-struct__subhead { + display: flex; + align-items: center; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-struct__title, +.m02-struct__subtitle { + margin: 0; + overflow: hidden; + color: var(--color-text); + font-size: var(--text-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-struct__no { + padding: 0 var(--spacing-8); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.m02-struct__head .ui-btn, +.m02-struct__subhead .ui-btn { + margin-left: auto; + white-space: nowrap; +} + +/* 미리보기 — 웹캐드는 높이를 부모에서 받음(`.m02-drawing` 이 flex: 1 1 0) */ +.m02-struct__preview { + display: flex; + height: 24rem; + min-height: 0; + min-width: 0; +} + +.m02-struct__sheet { + min-width: 0; +} + +.m02-struct__empty { + margin: 0; + padding: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts new file mode 100644 index 00000000..e19048bb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -0,0 +1,257 @@ +/* ============================================================================= + * M02_MasterTemplete_Structure.ts + * 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표. + * + * 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) · + * 도번은 서버가 매김(`구-01` 꼴 · 없으면 「없음」) · 문서 = {열 · 도번 · 도면(CAD) · 산출근거(표)}. + * 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림. + * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet"; +import { + createStructure, + fetchStructureNumbers, + readTemplate, + saveTemplate, + StaleError, + type StructureDoc, +} from "./M02_MasterTemplete_Api_Fetch"; +import { + mountDrawingTemplate, + type DrawingTemplateDoc, + type DrawingTemplateHandle, +} from "./M02_MasterTemplete_Drawing"; +import "./M02_MasterTemplete_Structure.css"; + +/** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */ +export const STRUCTURE_TABLE = "구조물집계표"; + +const TEXT = { + edit: "도면 수정", + save: "산출근거 저장", + basis: "상세 산출근거", + noNumber: "도번 없음", + noDrawing: "도면 없음 — [도면 수정] 을 누르면 새로 만들어 엶", + noBasis: "도면이 아직 없어 산출근거도 없음", + pick: "집계표에서 열을 고를 것", + created: "구조물 도면을 만들었음 — {value}", + saved: "산출근거를 저장했음", + dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?", + kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것", + stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것", + readFailed: "구조물 도면을 못 읽음 — {value}", + makeFailed: "구조물 도면을 못 만듦 — {value}", + saveFailed: "저장 못 함 — {value}", +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const fill = (text: string, value: string): string => text.replace("{value}", value); + +/** 집계표 열 — 머리 글만 씀 */ +export interface StructureColumn { + id: string; + 머리: (string | null)[]; +} + +/** 열 머리 글 — 층을 ` · ` 로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ +export const columnHead = (col: StructureColumn): string => + col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" · ") || col.id; + +export interface StructurePanelOptions { + /** 시스템 관리자만 새로 만들고 저장함 */ + isAdmin: boolean; + /** [도면 수정] — 그 열 구조물 도면을 CAD 편집으로 엶(페이지의 `open`) */ + onEdit: (column: string) => void; + /** 새로 만든 뒤 — 집계표 도면 줄을 새 도번으로 다시 그림 */ + onCreated: () => void; + /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ + confirmLeave: () => Promise; +} + +export interface StructurePanelHandle { + root: HTMLElement; + /** `{열 id: 도번}` — 집계표 `drawingLabel` 이 씀 */ + numbers: () => Record; + /** 도번 목록 다시 받기 */ + reload: () => Promise; + /** 고른 열이 바뀜 — 같은 열이면 아무것도 안 함(CAD 를 다시 안 띄움) */ + show: (colId: string | null, columns: StructureColumn[]) => void; + destroy: () => void; +} + +export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle { + let numbers: Record = {}; + let column: string | null = null; + let doc: StructureDoc | null = null; + let version = ""; + let preview: DrawingTemplateHandle | null = null; + let basis: SheetHandle | null = null; + let basisDirty = false; + let busy = false; + let seq = 0; + + const title = el("h3", { className: "m02-struct__title", text: TEXT.pick }); + const number = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: TEXT.edit, variant: "filled" }); + const saveBasis = createButton({ label: TEXT.save, variant: "ghost" }); + const previewHost = el("div", { className: "m02-struct__preview" }); + const basisHost = el("div", { className: "m02-struct__sheet" }); + const root = el("section", { + className: "m02-struct", + attrs: { hidden: "" }, + children: [ + el("div", { className: "m02-struct__head", children: [title, number, edit] }), + previewHost, + el("div", { + className: "m02-struct__subhead", + children: [el("h4", { className: "m02-struct__subtitle", text: TEXT.basis }), saveBasis], + }), + basisHost, + ], + }); + + const drop = (): void => { + preview?.destroy(); + preview = null; + basis?.destroy(); + basis = null; + basisDirty = false; + }; + + const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); + + /** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글 */ + const paintBody = (): void => { + drop(); + number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber; + edit.hidden = !opts.isAdmin; + saveBasis.hidden = !opts.isAdmin || !doc; + if (!doc) { + previewHost.replaceChildren(note(TEXT.noDrawing)); + basisHost.replaceChildren(note(TEXT.noBasis)); + return; + } + previewHost.replaceChildren(); + basisHost.replaceChildren(); + // 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠. + preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: true, + name: `preview:${column ?? ""}`, + layer: "system", + }); + basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, { + mode: "master", + onChange: () => (basisDirty = true), + }); + }; + + async function load(col: StructureColumn | null): Promise { + const mine = ++seq; + drop(); + if (!col) { + root.hidden = true; + column = null; + doc = null; + return; + } + root.hidden = false; + column = col.id; + title.textContent = columnHead(col); + number.textContent = numbers[col.id] ?? TEXT.noNumber; + doc = null; + version = ""; + previewHost.replaceChildren(); + basisHost.replaceChildren(); + try { + const got = await readTemplate("system", "structure", col.id, null); + if (mine !== seq) return; + doc = got.문서 as StructureDoc; + version = got.판 ?? ""; + } catch (error) { + if (mine !== seq) return; + // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 + if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error"); + } + paintBody(); + } + + const show = (colId: string | null, columns: StructureColumn[]): void => { + if (colId === column) return; + const next = colId ? (columns.find((c) => c.id === colId) ?? null) : null; + if (!basisDirty) return void load(next); + void showConfirmDialog(TEXT.dirty).then((ok) => { + if (!ok) return void showToast(TEXT.kept, "info"); + basisDirty = false; + void load(next); + }); + }; + + saveBasis.addEventListener("click", async () => { + const at = column; + if (!at || !doc || !basis || busy) return; + busy = true; + saveBasis.disabled = true; + try { + const next: StructureDoc = { + ...doc, + 산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"], + }; + const info = await saveTemplate("system", "structure", at, null, version, next); + version = info.판; + doc = next; + basisDirty = false; + showToast(TEXT.saved, "success"); + } catch (error) { + showToast( + error instanceof StaleError ? TEXT.stale : fill(TEXT.saveFailed, why(error)), + "error", + ); + } finally { + busy = false; + saveBasis.disabled = false; + } + }); + + edit.addEventListener("click", async () => { + const at = column; + if (!at || busy) return; + if (basisDirty && !(await showConfirmDialog(TEXT.dirty))) return; + basisDirty = false; + if (!(await opts.confirmLeave())) return; + if (!doc) { + busy = true; + edit.disabled = true; + try { + const got = await createStructure(at); + doc = got.문서 as StructureDoc; + version = got.판 ?? ""; + numbers = await fetchStructureNumbers(); + showToast(fill(TEXT.created, doc.도번), "success"); + opts.onCreated(); // 좌측 목록 · 집계표 도면 줄 + } catch (error) { + showToast(fill(TEXT.makeFailed, why(error)), "error"); + return; + } finally { + busy = false; + edit.disabled = false; + } + } + opts.onEdit(at); + }); + + return { + root, + numbers: () => numbers, + reload: async () => { + numbers = await fetchStructureNumbers().catch(() => ({})); + }, + show, + destroy: () => { + seq += 1; + drop(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 2aebe784..3dfe7e5c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -14,7 +14,14 @@ import { StaleError, type Kind, type Layer, + type StructureDoc, } from "./M02_MasterTemplete_Api_Fetch"; +import { + createStructurePanel, + STRUCTURE_TABLE, + type StructureColumn, + type StructurePanelHandle, +} from "./M02_MasterTemplete_Structure"; export interface Selection { layer: Layer; @@ -28,6 +35,12 @@ interface EditorHandle { destroy: () => void; } +/** 표 부품 손잡이 — 도면 줄 도번을 다시 그리려고 `recalc` 을 씀 */ +interface SheetEditorHandle extends EditorHandle { + getDoc: () => { 열: StructureColumn[] }; + recalc: () => void; +} + type Mods = Record Promise>; // 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음 const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts"); @@ -65,12 +78,14 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); + const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너 const root = el("div", { className: "m02-main", children: [ el("div", { className: "m02-main__head", children: [title, slot, reload, save] }), notice, host, + extra, ], }); @@ -78,6 +93,8 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { let version = ""; let loaded: unknown = null; let editor: EditorHandle | null = null; + let sheet: SheetEditorHandle | null = null; + let panel: StructurePanelHandle | null = null; let seq = 0; let dirty = false; @@ -111,9 +128,31 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const drop = (): void => { editor?.destroy(); editor = null; + sheet = null; + panel?.destroy(); + panel = null; + extra.replaceChildren(); slot.replaceChildren(); }; + /** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 미리보기 · [도면 수정] · 상세 산출근거 */ + function buildPanel(): StructurePanelHandle { + const made = createStructurePanel({ + isAdmin, + confirmLeave, + // 같은 페이지에서 그 구조물 도면을 CAD 편집으로 엶 · 좌측 목록을 다시 받아 + // 고름 표시를 옮김(`sel` 은 `open` 이 기다리기 전에 이미 새 것 · 목록은 `getOpen` 을 봄) + onEdit: (column) => { + void open({ layer: "system", projectId: null, kind: "structure", name: column }); + onSaved?.(); + }, + // 새로 만든 뒤 — 집계표 도면 줄을 새 도번으로 다시 그림 + onCreated: () => sheet?.recalc(), + }); + extra.replaceChildren(made.root); + return made; + } + async function mount(doc: unknown, at: Selection): Promise { const readOnly = !canEdit(at.layer, isAdmin); host.replaceChildren(); @@ -122,21 +161,33 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { SHEET_MODS, "createSheet", ); - if (create) { - editor = create(host, doc, { - mode: at.layer === "project" ? "project" : "master", - onChange: () => (dirty = !readOnly), - }); - return; - } - return empty(L("M02_NoSheet")); + if (!create) return empty(L("M02_NoSheet")); + // 구조물집계표(시스템 층)만 — 도면 줄에 도번 · 칸을 고르면 아래 컨테이너 + const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null; + if (struct) await struct.reload(); + panel = struct; + editor = create(host, doc, { + mode: at.layer === "project" ? "project" : "master", + onChange: () => (dirty = !readOnly), + ...(struct + ? { + drawingLabel: (colId: string) => struct.numbers()[colId] ?? "", + onSelect: (s: { colId: string | null }) => + struct.show(s.colId, sheet?.getDoc().열 ?? []), + } + : {}), + }); + if (struct) sheet = editor as SheetEditorHandle; + return; } const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( DRAWING_MODS, "mountDrawingTemplate", ); if (mountDrawing) { - editor = mountDrawing(host, doc, { + // 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번 · 산출근거는 안 건드림 + const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc; + editor = mountDrawing(host, cad, { onSave: () => void doSave(), readOnly, name: at.name, @@ -176,6 +227,11 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { ) : got.문서; if (mine !== seq) return; + // 구조물 도면은 열 id 로 열리니 머리에 도번을 함께 보임 + if (next.kind === "structure") { + const number = (got.문서 as StructureDoc)?.도번; + if (number) title.textContent = `${number} · ${next.name}`; + } await mount(shown, next); } catch (error) { if (mine !== seq) return; @@ -189,7 +245,12 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { if (!sel) return; const at = sel; try { - const doc = editor ? await editor.getDoc() : loaded; + const edited = editor ? await editor.getDoc() : loaded; + // 구조물 도면 — CAD 는 `도면` 칸만 다룸 · 도번 · 산출근거는 읽은 그대로 둠 + const doc = + at.kind === "structure" && loaded + ? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] } + : edited; const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); version = info.판; loaded = doc; diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py new file mode 100644 index 00000000..e18cd0a5 --- /dev/null +++ b/resources/tester/test_m02_structure_panel.py @@ -0,0 +1,93 @@ +"""M02 구조물 설계 컨테이너(`M02_MasterTemplete_Structure.ts`) 붙는 자리 시험. + +화면 조작은 ORCA 로 봄 — 여기서는 창끼리 계약이 안 어긋나는지만 봄. + · 집계표 이름이 서버(`STRUCTURE_TABLE`)와 같은 글인가 + · 컨테이너가 쓰는 이름(표 부품 · 도면 부품 · 서버 호출)이 정말 내보내진 것인가 + · 열 머리 글 잇기(`columnHead`)를 Node 로 바로 돌려 봄 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +PANEL = M02 / "M02_MasterTemplete_Structure.ts" +MAIN = M02 / "M02_MasterTemplete_UI_Main.ts" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_집계표_이름이_서버와_같음(): + found = re.search(r'STRUCTURE_TABLE = "([^"]+)"', _text(PANEL)) + assert found and found.group(1) == store.STRUCTURE_TABLE + + +def test_소스가_700줄을_넘지_않음(): + for path in (PANEL, MAIN, M02 / "M02_MasterTemplete_Structure.css"): + assert len(_text(path).splitlines()) <= 700, path.name + + +@pytest.mark.parametrize( + ("source", "names"), + [ + ( + "M02_MasterTemplete_Api_Fetch.ts", + ["createStructure", "fetchStructureNumbers", "readTemplate", "saveTemplate"], + ), + ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"]), + ("../ui_template/sheet/ui_template_sheet.ts", ["createSheet"]), + ], +) +def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: list[str]): + """sub2·sub3 가 이름을 바꾸면 여기서 먼저 걸림.""" + body = _text(M02 / source) + panel = _text(PANEL) + for name in names: + assert re.search(rf"export (const|function|interface) {name}\b", body), f"{source}:{name}" + assert name in panel, name + + +def test_집계표만_컨테이너를_붙임(): + """다른 표 양식에는 안 붙음 — 시스템 층 · 이름이 집계표일 때만.""" + body = _text(MAIN) + assert 'at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null' in body + # 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로 + assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_열_머리_글_잇기(): + """`columnHead` — 빈 층은 건너뛰고 ` · ` 로 이음 · 다 비면 열 id.""" + found = re.search(r"^export const columnHead[\s\S]*?;$", _text(PANEL), re.M) + assert found, "columnHead 를 못 찾음" + # 부품 파일은 화면 모듈을 부르므로 통째로 못 싣음 — 그 함수만 떼어 Node 가 타입을 벗겨 돌림 + script = ( + "interface StructureColumn { id: string; 머리: (string | null)[] }\n" + + found.group(0).replace("export ", "") + + "\nconsole.log(JSON.stringify(process.argv.slice(1)" + + ".map((a) => columnHead(JSON.parse(a)))));" + ) + cases = [ + {"id": "pv_l", "머리": ["콘크리트포장", "T=(두께별)", "L"]}, + {"id": "no", "머리": ["NO", None, None]}, + {"id": "etc", "머리": [None, " ", None]}, + ] + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, + *[json.dumps(c, ensure_ascii=False) for c in cases]], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["콘크리트포장 · T=(두께별) · L", "NO", "etc"]