Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5

This commit is contained in:
2026-10-05 23:53:35 +09:00
12 changed files with 488 additions and 6 deletions
@@ -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<Record<string, StructureSpec>> = {
pipe: PIPE_SPEC,
ford_bridge: FORD_SPEC,
pavement_concrete: PAVEMENT_SPEC,
};
@@ -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",
],
};
@@ -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,
@@ -56,6 +56,12 @@ export function numberDuplicateNames(names: ReadonlyArray<string>): 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),
),
);
@@ -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<string, string
if (input.isEmpty()) return;
values[input.key] = input.read();
});
return values;
return { ...values, ...readStructureSpec(ctx.optionRow, ctx.currentType()) };
}
export function emit(ctx: StructuresCommitContext): void {
@@ -369,7 +372,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
options: mergeStructureOptions(
previous,
type.type_id,
[...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()],
[
...ctx.optionInputs.map((entry) => entry.key),
...ctx.groupBoxes.shapeKeys(),
...specRenderedKeys(type, previous.options), // 명세 폼 키(52-10) — 비운 칸 · 뺀 컨테이너는 지움
],
records[0].options,
),
};
@@ -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;
@@ -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<HTMLElement, SpecForm>();
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
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<string> {
const spec = structureSpecOf(type);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []);
}
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
export function paintStructureSpec(
optionRow: HTMLElement,
type: StructureType | null,
values: Record<string, string | number>,
): 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<string, string | number> {
const form = forms.get(optionRow);
return form && structureSpecOf(type) ? form.read() : {};
}
/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */
export function specRenderedKeys(
type: StructureType | null,
previous: Record<string, string | number> | 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];
}
@@ -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;
}
@@ -3232,7 +3232,7 @@
"options": [
{
"key": "name",
"label": "이름",
"label": "상세 종류 이름",
"input": "text",
"default": "기타 구조물"
}
+37 -1
View File
@@ -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)));
@@ -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"]
@@ -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);
})();