diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index ffc019894..f00fef9ad 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -13,6 +13,7 @@ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; import { FORD_SPEC } from "./design_structure_ford"; +import { PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -188,4 +189,5 @@ export const PIPE_SPEC: StructureSpec = { export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC, ford_bridge: FORD_SPEC, + pavement_concrete: PAVEMENT_SPEC, }; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts new file mode 100644 index 000000000..4d1f7af13 --- /dev/null +++ b/A00_Common/design/design_structure_road.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * design_structure_road.ts + * 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`). + * + * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. + * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. + * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */ +export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }]; + +export const PAVEMENT_SPEC: StructureSpec = { + typeId: "pavement_concrete", + containers: [ + { + id: "detail", + title: "상세 내용", + required: true, + link: "gpavedetail", + items: [ + { + id: "spec", + label: "포장사양", + kind: "select", + key: "pave_spec", + choices: PAVE_SPEC_CHOICES, + }, + { id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 }, + ], + }, + ], + // 표 08 에 없는 옛 상세 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + ], +}; diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index c0e28f3fc..d2d4889bb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,6 +28,7 @@ import { createFacilityDefinition, type FacilityDefinition, } from "./B05_Profile_UI_Structures_Define_Form"; +import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec"; export interface StructuresFormElements { root: HTMLElement; @@ -164,6 +165,13 @@ export function buildStructuresForm(options: { set: groupBoxes.setOne, }); groupBoxes.watch(facilityOptions.paintLinks); + // 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음 + const structureSpec = createStructureSpecForm( + optionRow, + { get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne }, + options.onFacilityChange, + ); + groupBoxes.watch(structureSpec.paintLinks); const staleBox = document.createElement("fieldset"); staleBox.className = "b05-structure__group b05-structure__stale"; staleBox.hidden = true; @@ -175,6 +183,7 @@ export function buildStructuresForm(options: { summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류) positionDivider, optionRow, + structureSpec.root, facilityOptions.root, staleBox, groupBoxes.root, diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 6f3d43b25..abb884a5d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -56,6 +56,12 @@ export function numberDuplicateNames(names: ReadonlyArray): string[] { }); } +/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */ +function etcName(structure: StructureInstance): string | null { + const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : ""; + return name || null; +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -89,7 +95,9 @@ export function renderStructureList(params: StructureListParams): void { const displayNames = numberDuplicateNames( rows.map((row) => row.kind === "structure" - ? (typeMap.get(row.structure.type_id)?.name ?? row.structure.type_id) + ? (etcName(row.structure) ?? + typeMap.get(row.structure.type_id)?.name ?? + row.structure.type_id) : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), ), ); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index e24155203..16f553da4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; +import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { readGroupItems, @@ -40,6 +41,8 @@ export interface StructuresCommitContext { startFields: StationFields; endFields: StationFields; facilityOptions: FacilityOptionsForm; + /** 옵션 칸 — 짝 명세 폼(52-10)을 찾는 열쇠. */ + optionRow: HTMLElement; optionInputs: Array<{ key: string; required: boolean; @@ -205,7 +208,7 @@ export function readOptions(ctx: StructuresCommitContext): Record entry.key), ...ctx.groupBoxes.shapeKeys()], + [ + ...ctx.optionInputs.map((entry) => entry.key), + ...ctx.groupBoxes.shapeKeys(), + ...specRenderedKeys(type, previous.options), // 명세 폼 키(52-10) — 비운 칸 · 뺀 컨테이너는 지움 + ], records[0].options, ), }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index af789a5dd..c61d97b8a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -22,6 +22,7 @@ import { import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { specKeys } from "../A00_Common/design/design_structure_spec"; +import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; /** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로 * 그리는 종류(PLAN 52-1 · 52-6b) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */ @@ -62,10 +63,15 @@ export function renderOptionFields( optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). + // 명세 종류(52-10)는 명세 키 · 명세가 뺀 옛 키를 칸으로 안 그림 — 명세 폼이 옵션 칸 아래에 그림 + const owned = specOwnedKeys(type); const shown = type && !ctx.facilityFormKind(type) - ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) + ? orderedOptions(type).filter( + (o) => (ctx.includeDetail || isB05Option(o)) && !owned.has(o.key), + ) : []; + paintStructureSpec(optionRow, type, values); optionRow.style.gridTemplateColumns = ""; if (!type || !shown.length) { optionRow.hidden = true; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts new file mode 100644 index 000000000..d63ecab14 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Spec.ts + * 일반 구조물(structures.json) 명세 컨테이너 폼(PLAN 52-10) — 계곡 통과 시설 밖 종류가 명세 + * (`STRUCTURE_SPECS`)를 가지면 옵션 칸 아래에 같은 공용 폼(`design_structure_form`)을 그림. + * + * · 폼은 옵션 칸(`optionRow`) 짝으로 하나 — 조립기가 세워 걸고, 옵션 그리기 · 저장이 꺼내 씀 + * · 명세 키 · 명세가 뺀 옛 키(retired)는 정의 옵션 칸으로 그리지 않음(옛 값은 병합이 지킴) + * · 잠금은 옵션 칸을 따름(측점 확정 전 흐림 · 조작 막음) + * ========================================================================== */ + +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { + createSpecForm, + type SpecForm, + type SpecLinks, +} from "../A00_Common/design/design_structure_form"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; + +const forms = new WeakMap(); + +/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ +export const structureSpecOf = (type: StructureType | null): StructureSpec | null => + (type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null; + +/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ +export function createStructureSpecForm( + optionRow: HTMLElement, + links: SpecLinks, + onChange: () => void, +): SpecForm { + const form = createSpecForm(links, { onChange }); + form.root.hidden = true; + forms.set(optionRow, form); + const lock = (): void => { + const locked = optionRow.classList.contains("is-locked"); + form.root.inert = locked; + form.root.classList.toggle("is-locked", locked); + }; + if (typeof MutationObserver !== "undefined") + new MutationObserver(lock).observe(optionRow, { + attributes: true, + attributeFilter: ["class"], + }); + return form; +} + +/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ +export function specOwnedKeys(type: StructureType | null): Set { + const spec = structureSpecOf(type); + return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []); +} + +/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ +export function paintStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, + values: Record, +): void { + const form = forms.get(optionRow); + const spec = structureSpecOf(type); + if (!form) return; + form.root.hidden = !spec; + if (!spec) return; + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + form.write(spec, values, type?.spec_names ?? {}, groups); +} + +/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ +export function readStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, +): Record { + const form = forms.get(optionRow); + return form && structureSpecOf(type) ? form.read() : {}; +} + +/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */ +export function specRenderedKeys( + type: StructureType | null, + previous: Record | undefined, +): string[] { + const spec = structureSpecOf(type); + if (!spec) return []; + const { keys, prefixes } = specKeys(spec); + const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); + return [...keys, ...own]; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index bb609526d..94df817ee 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -190,6 +190,7 @@ /* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시). 조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */ .b05-structure__grid.is-locked, +.b05-structure__spec.is-locked, .b05-drainage__facility.is-locked { opacity: 0.5; } diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index c2dbb92a6..6e0503a6b 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3232,7 +3232,7 @@ "options": [ { "key": "name", - "label": "이름", + "label": "상세 종류 이름", "input": "text", "default": "기타 구조물" } diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 168721216..3d063ce2c 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -18,7 +18,43 @@ const load = (file, deps = {}) => { return box.exports; }; const s = load("design_structure_spec.ts"); -const { PIPE_SPEC } = load("design_structure_pipe.ts"); +const road = load("design_structure_road.ts"); +const { PIPE_SPEC, STRUCTURE_SPECS } = load("design_structure_pipe.ts", { + "./design_structure_road": road, +}); + +// 노면공 › 콘크리트 포장(PLAN 52-10) — 옛 상세 칸은 남고 명세 키만 오감 +if (process.argv[2] === "road") { + const spec = STRUCTURE_SPECS.pavement_concrete; + const old = { + length_m: 12, + before_m: 4, + after_m: 8, + thickness_cm: 20, + joint_spacing_m: 6, + pave_spec: "콘크리트", + height_m: 0.2, + }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].height = "0.35"; + const cleared = JSON.parse(JSON.stringify(state)); + cleared.detail[0].height = ""; + process.stdout.write( + JSON.stringify({ + registered: Object.keys(STRUCTURE_SPECS), + same: spec === road.PAVEMENT_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]), + choices: road.PAVE_SPEC_CHOICES.map((c) => c.value), + state, + edited: s.writeOptions(spec, edited), + cleared: s.writeOptions(spec, cleared), + keys: s.specKeys(spec), + retired: spec.retired, + }), + ); + process.exit(0); +} if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); diff --git a/resources/tester/test_52_10_structure_spec_road.py b/resources/tester/test_52_10_structure_spec_road.py new file mode 100644 index 000000000..395211b1f --- /dev/null +++ b/resources/tester/test_52_10_structure_spec_road.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""노면공 › 콘크리트 포장 명세 · 기타 이름(사용자 구조물 표 08) — PLAN 52-10. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs road`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +DEFINITION = os.path.join(ROOT, "resources", "master_template", "구조물정의.json") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "road"], + 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 {"pipe", "pavement_concrete"} <= set(out["registered"]) # 다른 창 명세도 함께 등록됨 + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", True, "gpavedetail"]] + + +def test_포장사양_선택지는_명세_한_곳(out): + assert out["choices"] == ["콘크리트"] + + +def test_저장_키_왕복_옛_칸은_명세_밖(out): + assert out["state"] == {"detail": [{"spec": "콘크리트", "height": "0.2"}]} + assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35} + assert out["cleared"] == {"pave_spec": "콘크리트"} + assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []} + assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로 + assert set(out["retired"]) == { + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + } + + +def test_기타_상세_종류_이름_글_칸(): + with open(DEFINITION, encoding="utf-8") as f: + doc = json.load(f) + etc = next(t for t in doc["types"] if t["type_id"] == "etc") + name = next(o for o in etc["options"] if o["key"] == "name") + assert name["label"] == "상세 종류 이름" and name["input"] == "text" + pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete") + assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"] diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js new file mode 100644 index 000000000..4ec66b9e8 --- /dev/null +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -0,0 +1,215 @@ +/* 52-10 — B05 · B06 좌측 「구조물 배치」 노면공 › 콘크리트 포장 · 기타 = 사용자 구조물 표 08 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_보조) · 폼 비어 있음 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5210 = "단계";` 를 붙여 + * orca eval --expression "window.__m5210='edit'; $(cat 이 파일)" → 두 종류 모양 · 값 · 잇기 · [추가] · [저장] + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m5210='verify'; $(cat 이 파일)" → 저장 값 그대로 · 같은 모양 + * (B06 으로 옮겨 같은 단계 — 같은 값 · 같은 모양) + * orca eval --expression "window.__m5210='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) + * 기대 수치: + * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · + * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 + * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · 종류 단위 잇기 줄 보임 · 지정 + * verify — 같은 값 · 같은 모양(B05 = B06) + */ +(async () => { + const MODE = window.__m5210 || "edit"; + const NAME = "52-10 시험 기타"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => !!n && n.offsetParent !== null; + const sec = () => document.querySelector(".b05-structure-section"); + const body = () => sec().querySelector(".b05-route__panel-body"); + const spec = () => sec().querySelector(".b05-structure__spec"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const ITEMS = [ + { typeId: "pavement_concrete", group: "G", at: [4, 6], list: /콘크리트 포장/ }, + { typeId: "etc", group: "호환", at: [5, 2], list: new RegExp(NAME) }, + ]; + const typeSummary = () => + [...body().children].find((c) => c.classList.contains("b05-structure__summary")); + const optionRow = () => + [...body().children].filter((c) => c.classList.contains("b05-structure__grid"))[1]; + const optionLabels = () => + vis(optionRow()) + ? [...optionRow().querySelectorAll(":scope > label > span:first-child")].map((s) => + s.textContent.trim(), + ) + : []; + const specCtl = (item) => + spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input"); + + const shape = (typeId) => { + const position = [...body().children].find((c) => + c.classList.contains("b05-structure__position-row"), + ); + const after = [...body().children] + .slice([...body().children].indexOf(position) + 1) + .find((c) => vis(c)); + const nameInput = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + ?.querySelector("input"); + const boxes = vis(spec()) + ? [...spec().querySelectorAll("[data-spec-container]")].filter(vis) + : []; + return { + typeId, + options: optionLabels(), + containers: boxes.map((b) => b.dataset.specContainer), + legend: boxes.map((b) => b.querySelector("legend").firstChild.textContent), + paveChoices: specCtl("spec") + ? [...specCtl("spec").options].filter((o) => o.value).map((o) => o.value) + : null, + paveSpec: specCtl("spec")?.value ?? null, + height: specCtl("height")?.value ?? null, + containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), + typeSummaryVisible: vis(typeSummary()), + typeSummaryAfterPosition: after === typeSummary(), + typeLink: !!typeSummary().querySelector(".b05-structure__summary-name.is-assigned"), + name: nameInput?.value ?? null, + stale: vis(sec().querySelector(".b05-structure__stale")), + locked: !!spec()?.inert, + }; + }; + const pickType = async (group, typeId) => { + const [g, t] = selects(); + fire(g, group); + await w(300); + fire(t, typeId); + await w(500); + }; + const linkFirstLeaf = async (button) => { + button.click(); + await until(() => document.querySelector(".ui-item-picker")); + await w(600); + const modal = document.querySelector(".ui-item-picker"); + const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const leaf = [...modal.querySelectorAll("button")].find((b) => { + const t = b.textContent.trim(); + return t && !/^[▸▾]/.test(t) && !ctl.includes(t); + }); + leaf?.click(); + await w(300); + [...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click(); + await w(500); + return !!leaf; + }; + const clickBtn = async (text) => { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === text && vis(b)) + ?.click(); + await w(1500); + }; + // B06 은 첫 눌림이 그 측점의 다른 항목(관 등)을 고르기도 함 — 목록이 다시 그려지므로 새로 찾아 한 번 더 + const open = async (item) => { + for (let k = 0; k < 3 && selects()[1].value !== item.typeId; k++) { + const li = listItems().find((l) => item.list.test(l.textContent)); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(800); + } + return selects()[1].value === item.typeId; + }; + const out = { mode: MODE, items: [] }; + + if (MODE === "edit") { + await until(() => listItems().length > 0); + out.before = listItems().length; + for (const item of ITEMS) { + await pickType(item.group, item.typeId); + const row = { lockedBeforePosition: shape(item.typeId).locked }; + const [no, dist] = sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(no, String(item.at[0])); + fire(dist, String(item.at[1])); + await w(300); + if (item.typeId === "pavement_concrete") { + fire(specCtl("spec"), "콘크리트"); + await w(200); + fire(specCtl("height"), "0.35"); + await w(200); + row.picked = await linkFirstLeaf( + spec().querySelector('[data-spec-link] [data-b05-summary="pick"]'), + ); + } else { + const input = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + .querySelector("input"); + input.value = NAME; + 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"]')); + } + Object.assign(row, shape(item.typeId), { phase: "edit" }); + sec().querySelector(".is-primary").click(); // 추가 + await w(800); + out.items.push(row); + } + out.after = listItems().length; + await clickBtn("저장"); + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "verify") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) { + out.items.push({ typeId: item.typeId, missing: true }); + continue; + } + await w(300); + out.items.push({ ...shape(item.typeId), phase: "verify" }); + const li = listItems().find((l) => item.list.test(l.textContent)); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(300); + } + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "restore") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) continue; + await clickBtn("삭제"); + } + await clickBtn("저장"); + out.left = listItems().length; + out.leftover = listItems().filter((l) => /52-10/.test(l.textContent)).length; + } + const [pave, etc] = out.items; + out.paveOk = + !!pave && + !pave.missing && + JSON.stringify(pave.containers) === '["detail"]' && + pave.legend[0] === "상세 내용" && + JSON.stringify(pave.paveChoices) === '["콘크리트"]' && + pave.paveSpec === "콘크리트" && + Number(pave.height) === 0.35 && + pave.containerLink && + !pave.typeSummaryVisible && + !pave.options.some((l) => /두께|줄눈|철망|비닐|포장 폭|확폭/.test(l)) && + ["길이", "기준측점 전", "기준측점 후"].every((l) => pave.options.some((o) => o.startsWith(l))); + out.etcOk = + !!etc && + !etc.missing && + etc.name === NAME && + etc.containers.length === 0 && + etc.typeSummaryVisible && + etc.typeSummaryAfterPosition && + etc.typeLink; + return JSON.stringify(out); +})();