diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index e6df8ec91..31af0f4c0 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -3,7 +3,7 @@ * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. * * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] - * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만) + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 @@ -19,6 +19,7 @@ import { linkOf, readState, solveSplit, + TEXT_MAX, writeOptions, type SpecContainer, type SpecItem, @@ -111,6 +112,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = now; return select; } + if (item.kind === "text") { + const input = document.createElement("input"); + input.type = "text"; + input.maxLength = TEXT_MAX; + input.value = values[item.id] ?? ""; + return input; + } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { input.type = "text"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index bf18474f7..144b2c494 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -14,7 +14,7 @@ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; import { BOX_SPEC } from "./design_structure_box"; import { FORD_SPEC } from "./design_structure_ford"; -import { PAVEMENT_SPEC } from "./design_structure_road"; +import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -192,4 +192,5 @@ export const STRUCTURE_SPECS: Readonly> = { box_culvert: BOX_SPEC, ford_bridge: FORD_SPEC, pavement_concrete: PAVEMENT_SPEC, + etc: ETC_SPEC, }; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts index 4d1f7af13..3568dc85b 100644 --- a/A00_Common/design/design_structure_road.ts +++ b/A00_Common/design/design_structure_road.ts @@ -5,6 +5,8 @@ * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) · + * 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`. * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ @@ -43,3 +45,18 @@ export const PAVEMENT_SPEC: StructureSpec = { "wire_mesh", ], }; + +export const ETC_SPEC: StructureSpec = { + typeId: "etc", + containers: [ + { + id: "detail", + title: "상세 내용", + required: false, + prefix: "etc_detail", + link: "getcdetail", + items: [{ id: "note", label: "비고", kind: "text" }], + }, + ], + retired: [], +}; diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 506858418..74acbdb46 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -4,7 +4,8 @@ * * · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[] * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개) - * · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후) + * · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) · + * 맞물림(길이 = 전 + 후) * · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래) * · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` · * 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터) @@ -31,7 +32,7 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - kind: "select" | "number" | "auto"; + kind: "select" | "number" | "text" | "auto"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; @@ -64,6 +65,9 @@ export interface StructureSpec { retired: string[]; } +/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */ +export const TEXT_MAX = 100; + /** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */ export type Instance = Record; export type SpecState = Record; @@ -135,7 +139,8 @@ export function readState( return state; } -/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */ +/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김 · + * 다 빈 선택 인스턴스는 첫 칸을 빈 글로 남겨 자리를 지킴 — 잇기 · 집계 줄 · 다시 열기). */ export function writeOptions( spec: StructureSpec, state: SpecState, @@ -143,6 +148,7 @@ export function writeOptions( const out: Record = {}; for (const c of spec.containers) { (state[c.id] ?? []).forEach((values, index) => { + const before = Object.keys(out).length; for (const item of c.items) { const key = keyOf(c, item, index + 1, values); const raw = (values[item.id] ?? "").trim(); @@ -152,10 +158,13 @@ export function writeOptions( if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100; continue; } - out[key] = raw; + out[key] = item.kind === "text" ? raw.slice(0, TEXT_MAX) : raw; if (c.required) Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also); } + const first = c.items.find((item) => item.kind !== "auto"); + if (!c.required && first && Object.keys(out).length === before) + out[instanceKey(c, index + 1, first)] = ""; }); } return out; diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index f94e3391b..964b1c188 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -90,8 +90,11 @@ def collect_items( 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item, 복사본}` · - `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b). + `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b) · + 기타는 `그룹` = 사용자가 적은 상세 종류 이름(52-14). """ + from M02_MasterTemplete.M02_Table_Fill_Spec import etc_label + types = types or structure_type_map() lengths = pipe_lengths or {} items: list[dict[str, Any]] = [] @@ -111,6 +114,7 @@ def collect_items( "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", "group_items": item.group_items, + **etc_label(item.type_id, item.options), } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 2de0d16c3..41f9d1ec5 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -4,7 +4,9 @@ 집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값. 열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`) 컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값. -짝: `A00_Common/design/design_structure_pipe.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. +짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — +시험이 대조. +기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. """ from __future__ import annotations @@ -20,8 +22,16 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { ("soil_add", "gsoiladd", "흙막이 추가"), ("chute_add", "gchuteadd", "도수로/돌붙임"), ), + "etc": (("etc_detail", "getcdetail", "상세 내용"),), } -_TEXT = ("structure", "form") +_TEXT = ("structure", "form", "note") + + +def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: + """기타 집계 건 이름 `{그룹: 상세 종류 이름}`(비면 「기타」) — 다른 종류는 빈 묶음.""" + if type_id != "etc": + return {} + return {"그룹": str((options or {}).get("name") or "").strip() or "기타"} def _as_options(values: dict[str, Any]) -> dict[str, Any]: @@ -57,7 +67,7 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str "options": {**item["options"], **_as_options(found[n])}, "summary_item": links.get(gid), "복사본": f"{item['복사본']}{GROUP_JOIN}{gid}", - "그룹": f"{title} {n}", + "그룹": f"{item.get('그룹', title)} {n}", } ) return rows diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts index 1728f1662..c3f5ab458 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -28,8 +28,8 @@ const TAG: Record, OptionsTextKey> = { optional: "SpecOptional", select: "FormSelect", number: "FormNumber", - auto: "FormAuto", text: "FormText", + auto: "FormAuto", bool: "FormBool", }; diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 3d063ce2c..fcfb65245 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -56,6 +56,33 @@ if (process.argv[2] === "road") { process.exit(0); } +// 기타(PLAN 52-14) — 선택 컨테이너 「상세 내용」 비고 글 · 길이 상한 · 비우기 · 이름 표 +if (process.argv[2] === "etc") { + const spec = STRUCTURE_SPECS.etc; + const old = { name: "배수판", etc_detail_2_note: "옛 비고", etc_detail_5_note: "둘째" }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].note = " 새 비고 "; + edited.detail[1].note = ""; + edited.detail.push({ note: "가".repeat(s.TEXT_MAX + 30) }); + const out = s.writeOptions(spec, edited); + process.stdout.write( + JSON.stringify({ + same: spec === road.ETC_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.prefix, c.link]), + items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]), + state, + edited: out, + max: s.TEXT_MAX, + longLen: out.etc_detail_3_note?.length ?? 0, + keys: s.specKeys(spec), + names: s.nameTable(spec), + links: [1, 2].map((n) => s.linkOf(spec.containers[0], n)), + }), + ); + process.exit(0); +} + if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); process.exit(0); diff --git a/resources/tester/test_52_14_etc_text.py b/resources/tester/test_52_14_etc_text.py new file mode 100644 index 000000000..beab8fabc --- /dev/null +++ b/resources/tester/test_52_14_etc_text.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""공용 틀 글 손 입력 · 기타 선택 컨테이너 · 기타 이름 = 집계 건 이름 — PLAN 52-14. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs etc`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`(52-14 덧). +""" + +import json +import os +import re +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows, etc_label +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + RV, + _save, + world, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "etc"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기타_선택_컨테이너_상세_내용(out): + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", False, "etc_detail", "getcdetail"]] + assert out["items"] == [["note", "비고", "text"]] + assert out["links"] == ["getcdetail1", "getcdetail2"] + + +def test_글_저장_비우기_길이_상한(out): + assert out["state"] == {"detail": [{"note": "옛 비고"}, {"note": "둘째"}]} # n 차례대로 당김 + edited = out["edited"] + assert edited["etc_detail_1_note"] == "새 비고" # 앞뒤 빈칸 뺌 + assert edited["etc_detail_2_note"] == "" # 비운 인스턴스도 자리를 지킴(잇기 · 집계 줄) + assert out["longLen"] == out["max"] == 100 + assert out["keys"] == {"keys": [], "prefixes": ["etc_detail_"]} + + +def test_이름_표에_글_항목(out): + assert out["names"] == {"detail": "상세 내용", "detail.note": "비고"} + + +def test_기타_이름이_집계_건_이름(): + assert etc_label("etc", {"name": " 배수판 "}) == {"그룹": "배수판"} + assert etc_label("etc", {"name": ""}) == {"그룹": "기타"} + assert etc_label("etc", {}) == {"그룹": "기타"} + assert etc_label("pipe", {"name": "x"}) == {} + item = { + "type_id": "etc", + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽", "etc_detail_2_note": ""}, + "복사본": "st1", + "그룹": "배수판", + } + rows = container_rows(item, {"getcdetail1": RV, "getcdetail2": MS}) + assert [r["그룹"] for r in rows] == ["배수판 1", "배수판 2"] + assert [r["복사본"] for r in rows] == ["st1__getcdetail1", "st1__getcdetail2"] + assert [r["summary_item"] for r in rows] == [RV, MS] + assert rows[0]["options"]["note"] == "왼쪽" + + +def test_집계_모음에_기타_이름과_컨테이너_건(world): # noqa: F811 — 고정물 + root = world["root"] + etc = { + "structure_id": "etc1", + "type_id": "etc", + "placement": "point", + "chainage_m": 60.0, + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽"}, + "group_items": {"getcdetail1": RV}, + } + blank = {**etc, "structure_id": "etc2", "chainage_m": 80.0, "options": {"name": ""}} + _save(root, [etc, blank], []) + items = {i["복사본"]: i for i in fill.collect_items(root)} + assert items["etc1"]["그룹"] == "배수판" + assert items["etc1__getcdetail1"]["그룹"] == "배수판 1" + assert items["etc1__getcdetail1"]["summary_item"] == RV + assert items["etc2"]["그룹"] == "기타" + + +def test_서버_컨테이너_표는_기타_명세와_짝(): + with open( + os.path.join(ROOT, "A00_Common/design/design_structure_road.ts"), encoding="utf-8" + ) as f: + text = f.read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["etc"]) diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index 4ec66b9e8..b7d2f5f81 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -10,7 +10,9 @@ * 기대 수치: * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 - * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · 종류 단위 잇기 줄 보임 · 지정 + * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · + * 선택 컨테이너 「상세 내용」(52-14) 셋 추가 · 비고 글(길이 상한 100) · 1 · 3 잇기 · 2 빼기 → + * 둘 남음(비고 ["52-14 비고", ""] · 잇기 둘 다 · 3 의 잇기가 2 로 당겨짐) · 종류 단위 잇기 줄 가림 * verify — 같은 값 · 같은 모양(B05 = B06) */ (async () => { @@ -75,6 +77,13 @@ paveSpec: specCtl("spec")?.value ?? null, height: specCtl("height")?.value ?? null, containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), + containerLinks: boxes.map( + (b) => !!b.querySelector(".b05-structure__summary-name.is-assigned"), + ), + notes: boxes.map( + (b) => b.querySelector('[data-spec-item="detail.note"] input')?.value ?? null, + ), + noteMax: spec()?.querySelector('[data-spec-item="detail.note"] input')?.maxLength ?? null, typeSummaryVisible: vis(typeSummary()), typeSummaryAfterPosition: after === typeSummary(), typeLink: !!typeSummary().querySelector(".b05-structure__summary-name.is-assigned"), @@ -152,7 +161,28 @@ input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); await w(200); - row.picked = await linkFirstLeaf(typeSummary().querySelector('[data-b05-summary="pick"]')); + // 52-14 — 상세 내용 셋 추가 · 비고 · 1 · 3 잇기 · 2 빼기 + for (let k = 0; k < 3; k++) { + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + } + const notes = () => [...spec().querySelectorAll('[data-spec-item="detail.note"] input')]; + for (const [k, text] of [ + [0, "52-14 비고"], + [1, "지울 것"], + ]) { + notes()[k].value = text; + notes()[k].dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + } + const pickBtns = () => [ + ...spec().querySelectorAll('[data-spec-link] [data-b05-summary="pick"]'), + ]; + row.picked = await linkFirstLeaf(pickBtns()[0]); + row.picked3 = await linkFirstLeaf(pickBtns()[2]); + row.addedShape = shape(item.typeId); + spec().querySelectorAll('[data-spec-remove="detail"]')[1].click(); + await w(300); } Object.assign(row, shape(item.typeId), { phase: "edit" }); sec().querySelector(".is-primary").click(); // 추가 @@ -207,9 +237,11 @@ !!etc && !etc.missing && etc.name === NAME && - etc.containers.length === 0 && - etc.typeSummaryVisible && - etc.typeSummaryAfterPosition && - etc.typeLink; + JSON.stringify(etc.containers) === '["detail","detail"]' && + JSON.stringify(etc.legend) === '["상세 내용 1","상세 내용 2"]' && + JSON.stringify(etc.notes) === '["52-14 비고",""]' && + JSON.stringify(etc.containerLinks) === "[true,true]" && + etc.noteMax === 100 && + !etc.typeSummaryVisible; return JSON.stringify(out); })(); diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 8d0a7cc51..f0f72fee0 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -39,7 +39,9 @@ /** 구조물집계표 채움 줄 수(전구간 고정 줄 뺌) — 52-1b. */ const filledRows = async () => (await m02Api.readFilled(projectId, "구조물집계표")).문서.줄.filter((r) => !r.고정).length; - const spec = () => document.querySelector(".b05-structure__spec"); + // 명세 폼은 둘(관 시설 · 일반 구조물 52-10) — 보이는 것 + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); const box = (c, i = 0) => spec().querySelectorAll(`[data-spec-container="${c}"]`)[i]; const ctl = (c, item, i = 0) => box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input");