Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-06 00:07:16 +09:00
17 changed files with 4122 additions and 127 deletions
+55 -10
View File
@@ -1,5 +1,5 @@
{
"판": 10,
"판": 11,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -382,13 +382,14 @@
},
{
"key": "wing_in",
"label": "날개벽(유입)",
"label": "유입 구조",
"input": "select",
"choices": [
"있음",
"없음"
"없음",
"날개벽",
"흙막이"
],
"default": "있음",
"default": "날개벽",
"required": false
},
{
@@ -417,13 +418,14 @@
},
{
"key": "wing_out",
"label": "날개벽(유출)",
"label": "유출 구조",
"input": "select",
"choices": [
"있음",
"없음"
"없음",
"날개벽",
"흙막이"
],
"default": "있음",
"default": "날개벽",
"required": false
},
{
@@ -3335,7 +3337,23 @@
"계곡통과시설",
"BOX_본체규격",
"날개벽"
]
],
"이름": {
"body.size": "본체 규격",
"body.size=2.0×2.0": "2.0 × 2.0",
"body.size=3.0×3.0": "3.0 × 3.0",
"body.size=사용자 입력": "사용자 입력",
"body.width": "가로 (m)",
"body.height": "세로 (m)",
"inlet.structure": "구조",
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
}
},
"ford_pavement": {
"동작": [
@@ -3464,6 +3482,33 @@
],
"형상": {}
}
],
"box_culvert": [
{
"id": "gboxbody",
"이름": "본체",
"옵션": [
"body_width_m",
"body_height_m"
],
"형상": {}
},
{
"id": "gboxin",
"이름": "유입 구조",
"옵션": [
"wing_in"
],
"형상": {}
},
{
"id": "gboxout",
"이름": "유출 구조",
"옵션": [
"wing_out"
],
"형상": {}
}
]
},
"배치": {},
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
/* 박스암거 명세 단위검증 헬퍼(PLAN 52-2) — `design_structure_spec.ts` · `design_structure_box.ts` 를
* 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌.
* 인자 `names` 면 박스암거 이름 표(정의 `종류.box_culvert.이름`)만 냄.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (file) => {
const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", file), "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
};
const s = load("design_structure_spec.ts");
const b = load("design_structure_box.ts");
const SPEC = b.BOX_SPEC;
if (process.argv[2] === "names") {
process.stdout.write(JSON.stringify(s.nameTable(SPEC)));
process.exit(0);
}
/** 화면이 하는 차례 그대로 — 옛 값 정리 → 명세 읽기 → 본체 칸 되찾기 → 저장 옵션. */
const roundTrip = (options, edit) => {
const state = s.readState(SPEC, b.boxOptionsIn(options));
Object.assign(state.body[0], b.bodyOf(options));
edit?.(state);
return { state, out: { ...s.writeOptions(SPEC, state), ...b.bodyOptions(state.body[0]) } };
};
const body = SPEC.containers.find((c) => c.id === "body");
const width = body.items.find((i) => i.id === "width");
const old = { body_width_m: 3, body_height_m: 2.5, wing_in: "있음", wing_in_length_m: 2 };
const out = {
containers: SPEC.containers.map((c) => [c.id, c.title, c.required]),
links: SPEC.containers.map((c) => s.linkOf(c, 1)),
sizes: body.items[0].choices.map((c) => c.value),
structures: SPEC.containers[1].items[0].choices.map((c) => c.value),
customHidden: s.branchOf(width, { size: "2.0×2.0" }),
customShown: s.branchOf(width, { size: "사용자 입력" }),
old: roundTrip(old),
preset: roundTrip({ body_width_m: 2, body_height_m: 2, wing_out: "없음" }),
toPreset: roundTrip(old, (st) => {
st.body[0].size = "3.0×3.0";
st.outlet[0].structure = "흙막이";
}),
toCustom: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => {
st.body[0].size = "사용자 입력";
st.body[0].width = "2.4";
st.body[0].height = "1.8";
}),
blank: roundTrip({}),
keys: s.specKeys(SPEC),
retired: SPEC.retired,
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""횡단배수 › 박스암거(표 02 고친 판) 명세 — PLAN 52-2.
순수 규칙은 Node 헬퍼(`helper_52_structure_spec_box.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-2_박스암거_컨테이너.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))
def _node(*args):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_52_structure_spec_box.cjs"), *args],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def out():
return _node()
def test_컨테이너_셋_모두_필수(out):
assert out["containers"] == [
["body", "본체", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
]
assert out["links"] == ["gboxbody", "gboxin", "gboxout"]
def test_선택지_표_02_그대로(out):
assert out["sizes"] == ["2.0×2.0", "3.0×3.0", "사용자 입력"]
assert out["structures"] == ["없음", "날개벽", "흙막이"]
def test_사용자_입력일_때만_가로_세로_칸(out):
assert out["customHidden"] is None
assert out["customShown"] == {"key": "body_width_m"}
def test_옛_값_읽기_규격_밖은_사용자_입력_있음은_날개벽(out):
st = out["old"]["state"]
assert st["body"][0] == {"size": "사용자 입력", "width": "3", "height": "2.5"}
assert st["inlet"][0]["structure"] == "날개벽"
back = out["old"]["out"]
assert back == {"body_width_m": 3, "body_height_m": 2.5, "wing_in": "날개벽"}
# 옛 날개벽 치수는 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "wing_in_length_m" not in back
assert "wing_in_length_m" in out["retired"]
def test_규격은_폭_높이_수로_저장_규격_칸은_저장_안_함(out):
assert out["preset"]["state"]["body"][0]["size"] == "2.0×2.0"
assert out["preset"]["out"] == {"body_width_m": 2, "body_height_m": 2, "wing_out": "없음"}
to = out["toPreset"]["out"]
assert to["body_width_m"] == 3 and to["body_height_m"] == 3
assert to["wing_out"] == "흙막이"
assert out["toCustom"]["out"] == {"body_width_m": 2.4, "body_height_m": 1.8}
assert "size" not in json.dumps(out["keys"])
def test_빈_값은_빈_채로(out):
assert out["blank"]["out"] == {}
assert out["blank"]["state"]["body"][0]["size"] == ""
def test_정의_파일에_이름_표_그룹_선택지가_올라_있음():
names = _node("names")
with open(
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert set(names) == set(doc["종류"]["box_culvert"]["이름"])
assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"]
box = next(t for t in doc["types"] if t["type_id"] == "box_culvert")
wing = {o["key"]: o for o in box["options"]}
for key in ("wing_in", "wing_out"):
assert wing[key]["choices"] == ["없음", "날개벽", "흙막이"]
@@ -38,7 +38,7 @@ def test_새_관만_기본값_빈_키에만():
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
basins._apply_new_defaults([box])
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음"
assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "날개벽"
def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
@@ -547,8 +547,9 @@ def test_box_culvert_wing_wall_options_defined():
for side in ("in", "out"):
toggle = options[f"wing_{side}"]
assert toggle.phase == "b05" and not toggle.required
assert toggle.choices == ["있음", "없음"]
assert toggle.default == "있음", f"wing_{side} 기본 설치"
# 표 02 고친 판(PLAN 52-2) — 유입 · 유출 구조 = 없음 · 날개벽 · 흙막이
assert toggle.choices == ["없음", "날개벽", "흙막이"]
assert toggle.default == "날개벽", f"wing_{side} 기본 구조"
for dim, value in expected.items():
option = options[f"wing_{side}_{dim}"]
assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}"
@@ -0,0 +1,180 @@
/* 52-2 — B05 · B06 좌측 「구조물 배치」 박스암거 = 사용자 구조물 표 02 고친 판(컨테이너 셋)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub3_검증) · 목록에 「BOX암거」
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m522 = "단계";` 를 붙여
* orca eval --expression "window.__m522='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠)
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m522='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기
* orca eval --expression "window.__m522='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* (연결이 끊기면 결과는 `sessionStorage.getItem("__522_out_{단계}")` 로 읽음)
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치(edit): 컨테이너 3(본체 · 유입 구조 · 유출 구조 · 모두 필수 · [추가] 0) · 옛 값 3 × 2.5 = 사용자 입력 ·
* 옛 「있음」 = 날개벽 · 규격 2.0 × 2.0 고르면 숫자 칸 0 · 사용자 입력 고르면 가로 · 세로 2 ·
* 구조 선택지 없음/날개벽/흙막이 · 잇기 본체 · 유입 지정 · 유출 지정 뒤 풂 ·
* 저장 = body 2.4 × 1.8 · wing_in 흙막이 · wing_out 없음 · group_items gboxbody · gboxin
* 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06)
*/
(async () => {
const MODE = window.__m522 || "edit";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"));
const isBox = (p) => p.facility === "box_culvert";
// 보이는 명세 폼(배관 · BOX 가 각자 폼을 가짐 — 숨은 것은 뺌)
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`);
const field = (c, item) => box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`);
const ctl = (c, item) => field(c, item)?.querySelector("select, input");
const set = async (c, item, v) => {
const el = ctl(c, item);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(200);
};
const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text);
const pickBox = async () => {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
/BOX암거$/.test(l.textContent.trim()),
);
// 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더(재선택 = 해제)
(li.querySelector("button") ?? li).click();
await w(800);
if (!box("body")) (li.querySelector("button") ?? li).click();
await until(() => !!box("body"));
await w(500);
};
const shape = () =>
[...spec().querySelectorAll("[data-spec-container]")].map((b) => ({
c: b.dataset.specContainer,
legend: b.querySelector("legend").firstChild.textContent,
items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem),
values: [...b.querySelectorAll("[data-spec-item]")].map(
(n) => n.querySelector("select, input").value,
),
link: b.querySelector(".b05-structure__summary-name")?.textContent,
}));
const linked = (c) =>
box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned");
const pickLink = async (c) => {
box(c).querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(500);
};
const out = { mode: MODE };
if (MODE === "edit") {
const before = await fetchApi.fetchDetailPipePoints(projectId);
// 원래 값은 처음 한 번만 둠(끊겨 다시 돌려도 고친 값으로 덮지 않음 · 되돌림이 비움)
if (!sessionStorage.getItem("__522_original"))
sessionStorage.setItem("__522_original", JSON.stringify(before.pipe_points));
out.original = before.pipe_points.find(isBox)?.options;
await pickBox();
const s0 = shape();
out.containers = s0.map((b) => [b.c, b.legend]);
out.allVisible = ["body", "inlet", "outlet"].every((c) => box(c)?.offsetParent !== null);
out.addButtons = spec().querySelectorAll("[data-spec-add]").length;
out.oldRead = {
body: s0[0].values,
inlet: ctl("inlet", "structure").value,
outlet: ctl("outlet", "structure").value,
};
out.sizes = labels("body", "size");
out.structures = labels("inlet", "structure");
await set("body", "size", "2.0×2.0");
out.presetNumberFields = ["width", "height"].filter((i) => field("body", i)).length;
await set("body", "size", "사용자 입력");
out.customNumberFields = ["width", "height"]
.filter((i) => field("body", i))
.map((i) => field("body", i).querySelector("label, span")?.textContent ?? i);
await set("body", "width", "2.4");
await set("body", "height", "1.8");
await set("inlet", "structure", "흙막이");
await set("outlet", "structure", "없음");
await pickLink("body");
await pickLink("inlet");
await pickLink("outlet");
out.linkPicked = ["body", "inlet", "outlet"].map(linked);
box("outlet").querySelector('[data-b05-summary="clear"]').click();
await w(300);
out.linkOutletCleared = !linked("outlet");
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
const after = await fetchApi.fetchDetailPipePoints(projectId);
const p = after.pipe_points.find(isBox);
out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) };
}
if (MODE === "verify") {
await pickBox();
const b05 = shape();
out.reopened = b05.map((b) => [b.c, b.values, b.link]);
out.linkReopened = ["body", "inlet", "outlet"].map(linked);
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await pickBox();
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__522_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const keys = [
"chainage_m",
"source",
"facility",
"start_m",
"end_m",
"options",
"summary_item",
"group_items",
];
const points = original.map((p) =>
Object.fromEntries(
keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]),
),
);
await fetchApi.saveDetailPipePoints(projectId, points);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("aislo:draft:pipes:") || k === "__522_original")
sessionStorage.removeItem(k);
const now = await fetchApi.fetchDetailPipePoints(projectId);
const pick = (list) =>
JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}]));
out.restored = pick(now.pipe_points) === pick(original);
}
// 긴 단계는 ORCA 연결이 끊길 수 있어 결과를 남김 — `sessionStorage.__522_out_{단계}` 로 읽음
sessionStorage.setItem(`__522_out_${MODE}`, JSON.stringify(out));
return JSON.stringify(out);
})();
@@ -0,0 +1,83 @@
/* 52-2b — B06 횡단 조정 줄(단 수 · 옵션 · 읽기)이 서는 자리 = 지금 보이는 명세 폼의 유입 · 유출 칸
* 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub3_검증) · 목록에 「배관 1」 「BOX암거」 「세월교 1」
* 부르는 법: orca eval --expression "$(cat 이 파일)" — 쓰는 것 없음(가짜 조정 사본을 세웠다 내림)
* 차례: 배관 1 → BOX암거 → 세월교 1 → 배관 1 (앞 시설 명세 폼이 숨은 채 남는 경우까지)
* 기대 수치: 모든 단계 · 양쪽 — 줄이 숨은 조상 없이 보이는 자리 · 그 시설 명세 폼의 inlet/outlet 칸 ·
* 숨은 명세 칸을 「쓸 수 있음」으로 보던 옛 판정 건수(oldWouldPickHidden)는 참고값
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const dock = await import(modUrl("/B06_Section/B06_Section_UI_Adjust_Dock.ts"));
const visibleSpec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => !n.closest("[hidden]"));
const firstContainer = () =>
visibleSpec()?.querySelector("[data-spec-container]")?.dataset.specContainer;
const pick = async (name, head) => {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find(
(l) => l.offsetParent !== null && name.test(l.textContent.trim()),
);
(li.querySelector("button") ?? li).click();
await w(800);
if (firstContainer() !== head) (li.querySelector("button") ?? li).click();
return until(() => firstContainer() === head, 8000);
};
// 옛 판정 — 바로 위 fieldset 만 봄
const oldUsable = (t) => !!t.closest("fieldset") && !t.closest("fieldset").hidden;
const fakePanel = (side) => {
const panel = document.createElement("div");
panel.dataset.side = side;
panel.dataset.panelTitle = "52-2b 시험";
const row = document.createElement("div");
row.className = "b06-structure-panel__struct--tier";
row.dataset.m522b = side;
panel.append(row);
return { panel, row };
};
const steps = [
[/배관 1$/, "pipe"],
[/BOX암거$/, "body"],
[/세월교 1$/, "ford"],
[/배관 1$/, "pipe"],
];
const out = { steps: [], oldWouldPickHidden: 0 };
for (const [name, head] of steps) {
const step = { name: String(name), shown: await pick(name, head) };
for (const side of ["inlet", "outlet"]) {
// 숨은 명세 폼의 같은 칸 — 옛 판정이 「쓸 수 있음」으로 봤는지
for (const s of document.querySelectorAll(
`[data-spec-container="${side}"] .b05-structure__adjust-slot`,
))
if (s.closest("[hidden]") && oldUsable(s)) out.oldWouldPickHidden++;
const { panel, row } = fakePanel(side);
dock.adoptAdjustPanel(panel);
step[side] = {
visible: !row.closest("[hidden]"),
container: row.closest("[data-spec-container]")?.dataset.specContainer ?? null,
inShownForm: !!visibleSpec()?.contains(row),
};
dock.releaseAdjustPanel(panel);
}
out.steps.push(step);
}
out.pass = out.steps.every(
(s) =>
s.shown &&
["inlet", "outlet"].every((k) => s[k].visible && s[k].inShownForm && s[k].container === k),
);
return JSON.stringify(out);
})();