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

This commit is contained in:
2026-10-06 23:16:39 +09:00
37 changed files with 2077 additions and 509 deletions
@@ -0,0 +1,58 @@
/* 공용 표(`ui_template/ui_template_data_table.ts`) 견본 — 토적표 모양 · 머리 3줄 · 30열 · 합계.
* 그냥 글(스크립트)로 읽어 씀 — Node 헬퍼와 화면 eval 이 같은 견본을 씀.
* const s = sampleDataTable(66); // { columns, rows, total, frozen } */
function sampleDataTable(n) {
const num = (key, label, group, decimals) => ({ key, label, group, kind: "number", decimals });
const trio = (pre, group) => [
num(pre + "_area", "단면적", group, 2),
num(pre + "_vol", "입적", group, 1),
num(pre + "_adj", "보정량", group, 1),
];
const pair = (pre, group) => [
num(pre + "_len", "거리", group, 1),
num(pre + "_area", "면적", group, 1),
];
const columns = [
{ key: "sta", label: "측점", width: 80 },
num("dist", "거리", [], 0),
...trio("cs", ["절토", "토사"]),
...trio("cr", ["절토", "연암"]),
...trio("ds", ["절토", "측구파기 토사"]),
...trio("dr", ["절토", "측구파기 암"]),
num("adj_sum", "보정량계", ["절토"], 1),
num("fill_area", "단면적", ["성토"], 2),
num("fill_vol", "입적", ["성토"], 1),
num("reuse", "유용토", [], 1),
num("diff", "차인토량", [], 1),
num("cum", "누가토량", [], 1),
...pair("gf", ["면고르기", "성토면"]),
...pair("gc", ["면고르기", "절토면"]),
...pair("tf", ["지장목제거", "성토"]),
...pair("tc", ["지장목제거", "절토"]),
...pair("rd", ["노면정리"]),
];
const rows = [];
let cum = 0;
for (let i = 0; i < n; i++) {
const r = {
sta: i % 5 ? `${120 + Math.floor(i / 5)}+${(i % 5) * 4}` : String(120 + i / 5),
dist: i ? 4 : 0,
};
columns.slice(2).forEach((c, j) => {
// 빈 값 · 큰 값 · 음수가 섞이게
r[c.key] =
(i + j) % 7 === 0
? null
: Math.round(((i * 37 + j * 101) % 997) * (j % 4 === 0 ? 13.7 : 1.13) * 100) / 100;
});
r.diff = Math.round((r.fill_vol ?? 0) - (r.cs_vol ?? 0)) / 1;
cum += r.diff;
r.cum = cum;
rows.push(r);
}
const total = { sta: "합계" };
columns.slice(1).forEach((c) => {
if (c.key !== "cum") total[c.key] = rows.reduce((s, r) => s + (r[c.key] ?? 0), 0);
});
return { columns, rows, total, frozen: 1 };
}
+79
View File
@@ -0,0 +1,79 @@
/* 공용 표(`ui_template/ui_template_data_table.ts`) 단위검증 헬퍼 —
* 트랜스파일해 Node 로 견본 표 HTML 을 만들고 판정할 값을 JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const req = (p) => (p.endsWith(".css") ? {} : require(p));
new Function("exports", "module", "require", js)(box.exports, box, req);
const m = box.exports;
const src = fs.readFileSync(path.join(__dirname, "fixture_data_table_sample.js"), "utf8");
const sampleDataTable = new Function(src + "; return sampleDataTable;")();
const s = sampleDataTable(66);
const html = m.renderDataTableHtml(s);
const head = html.split("<thead>")[1].split("</thead>")[0];
const headRows = head.split("</tr>").filter(Boolean);
const body = html.split("<tbody>")[1].split("</tbody>")[0];
const bodyRows = body.split("</tr>").filter(Boolean);
const foot = html.split("<tfoot>")[1].split("</tfoot>")[0];
const t0 = process.hrtime.bigint();
const big = m.renderDataTableHtml(sampleDataTable(1000));
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
const one = (c, v) =>
m.formatCell(
v,
c.kind === "number"
? new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.decimals ?? 0,
maximumFractionDigits: c.decimals ?? 0,
})
: null,
)[0];
const nc = { key: "a", label: "a", kind: "number", decimals: 2 };
const tc = { key: "b", label: "b" };
console.log(
JSON.stringify({
cols: s.columns.length,
headRows: headRows.length,
firstHeadRow: headRows[0],
secondHeadRow: headRows[1],
bodyRows: bodyRows.length,
firstBodyRow: bodyRows[0],
foot,
frozenCells: (body.match(/class="is-frozen is-edge"/g) || []).length,
bigRows: big.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").length - 1,
bigMs: ms,
fmt: {
thousands: one(nc, 1234567.891),
neg: one(nc, -97.9),
zero: one(nc, 0),
nul: one(nc, null),
undef: one(nc, undefined),
blank: one(nc, ""),
nan: one(nc, NaN),
text: one(tc, "120+18"),
textNum: one(tc, 5),
},
win: (() => {
const b = m
.renderDataTableHtml(sampleDataTable(1000), { from: 61, to: 121, rowH: 25 })
.split("<tbody>")[1]
.split("</tbody>")[0]
.split("</tr>")
.filter(Boolean);
return { n: b.length, top: b[0], second: b[1].slice(0, 20), bottom: b[b.length - 1] };
})(),
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
}),
);
@@ -0,0 +1,73 @@
"""추천 「종단 반영」 켜기 · 끄기(PLAN 64-1) — 점수 분기 시험.
./venv/Scripts/python.exe -m pytest resources/tester/test_64_1_profile_toggle.py -q
켬(기본) = 후보마다 균형 종단을 세워 토공 점수를 그것으로(63-4) · 끔 = 종단 없이 원지반 가늠
(절 + 성). 끈 요청은 켠 요청이 묶음 캐시에 남긴 종단을 걷어 내고 처음부터 끈 것과 같아야 한다.
"""
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import COST_EARTH_M3
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest
GRADE = 0.14
RADIUS = 12.0
@pytest.fixture(scope="module")
def hill() -> Terrain:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
def _run(terrain: Terrain, mode: str, with_profile: bool) -> dict:
expected = [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)]
return generate_initial_route(
expected,
terrain,
[],
mode=mode,
weights=None,
max_grade=GRADE,
min_radius_m=RADIUS,
with_profile=with_profile,
)
@pytest.mark.parametrize("mode", ["earthwork", "cost", "follow"])
def test_켬은_종단_끔은_원지반(hill: Terrain, mode: str) -> None:
on = _run(hill, mode, True)
off = _run(hill, mode, False)
assert on["with_profile"] is True and off["with_profile"] is False
assert "error" not in on["metrics"]["profile"]
assert "profile" not in off["metrics"] and "earth_estimate_m3" not in off["metrics"]
# 끔 점수 = 절 + 성 · 켬 점수 = 균형 종단 토공
assert GOALS["earthwork"](off["metrics"]) == (
off["metrics"]["cut_m3"] + off["metrics"]["fill_m3"],
)
assert GOALS["earthwork"](on["metrics"]) == (profile_earth(on["metrics"]["profile"]),)
# 공사비 어림 = 켬은 토공 몫을 종단 점수로 바꾼 값(고른 노선이 달라도 그 노선의 처음 값 기준)
metrics = on["metrics"]
shift = COST_EARTH_M3 * (profile_earth(metrics["profile"]) - metrics["earth_estimate_m3"])
assert metrics["cost_index"] == pytest.approx(metrics["cost_index_base"] + shift, abs=1.0)
def test_끈_요청은_묶음_캐시의_종단을_걷어_낸다(hill: Terrain) -> None:
first = _run(hill, "cost", False)
_run(hill, "cost", True) # 같은 묶음 캐시에 종단을 붙임
again = _run(hill, "cost", False)
assert again["metrics"] == first["metrics"] and again["planned"] == first["planned"]
assert "profile" not in again["metrics"]
def test_요청_기본은_켬이고_끔을_받는다() -> None:
assert RouteInitialRequest(mode="earthwork").with_profile is True
assert RouteInitialRequest(mode="earthwork", with_profile=False).with_profile is False
+119
View File
@@ -0,0 +1,119 @@
"""B08 4탭 홍수량 계산서 — 처음 값 빈칸 · 공사지 지역 · 재현기간별 R24 · 저장 파일 (PLAN 69-5 · 69-6)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Site import ( # noqa: E402
SAVED_FILE,
read_saved,
site_info,
site_r24,
write_saved,
)
SQUARE = [[[128.0, 36.0], [129.0, 36.0], [129.0, 37.0], [128.0, 37.0], [128.0, 36.0]]]
FAR = [[[120.0, 30.0], [121.0, 30.0], [121.0, 31.0], [120.0, 31.0], [120.0, 30.0]]]
def _geojson(*features: tuple[list, dict]) -> str:
return json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": p,
"geometry": {"type": "Polygon", "coordinates": g},
}
for g, p in features
],
},
ensure_ascii=False,
)
@pytest.fixture()
def project(tmp_path: Path) -> Path:
drainage = tmp_path / "B04_PreProcess" / "drainage"
processed = tmp_path / "B04_PreProcess" / "processed"
drainage.mkdir(parents=True)
processed.mkdir(parents=True)
rain = {
"lat": 36.5,
"lon": 128.5,
"station": {"name": "영양군(도곡리)"},
"values": [
{"return_period_yr": 50, "duration_min": 1440, "depth_mm": 207.69},
{"return_period_yr": 100, "duration_min": 1440, "depth_mm": 231.56},
{"return_period_yr": 100, "duration_min": 60, "depth_mm": 70.0},
],
}
(drainage / "rainfall_table.json").write_text(json.dumps(rain), encoding="utf-8")
(processed / "행정구역_시군구_bounds.geojson").write_text(
_geojson(
(FAR, {"sig_kor_nm": "봉화군", "full_nm": "경상북도 봉화군"}),
(SQUARE, {"sig_kor_nm": "영양군", "full_nm": "경상북도 영양군"}),
),
encoding="utf-8",
)
(processed / "행정구역_읍면동_bounds.geojson").write_text(
_geojson((SQUARE, {"full_nm": "경상북도 영양군 일월면 용화리"})), encoding="utf-8"
)
return tmp_path
def test_처음_값은_표준_값만_측점_고유_값은_빈칸() -> None:
for key in ("area_km2", "length_m", "height_m", "diameter_mm", "slope_denominator", "r24_mm"):
assert DEFAULTS[key] is None
assert DEFAULTS["factor"] == 2.0 and DEFAULTS["runoff"] == 0.8
assert DEFAULTS["return_period"] == 100 and DEFAULTS["section"] == "full"
def test_빈칸이면_그_값에_기대는_칸만_비움() -> None:
out = compute(DEFAULTS)
assert out["design_flow"] is None and out["capacity"] is None
assert out["ok"] is None and out["ratio_pct"] is None
assert out["n"] == 0.024 # 관 종류 값은 늘 있음
# 단면만 채우면 유출량은 나오고 판정은 빈칸
half = compute({**DEFAULTS, "diameter_mm": 1000.0, "slope_denominator": 20.0})
assert half["capacity"] > 0 and half["design_flow"] is None and half["ok"] is None
def test_지역은_노선_대표점이_드는_행정구역(project: Path) -> None:
site = site_info(project, db_region="엉뚱")
assert site["region"]["name"] == "영양군"
assert site["region"]["full"] == "경상북도 영양군 일월면 용화리"
assert site["station"] == "영양군(도곡리)"
assert site["periods"] == [50, 100]
assert site["r24"] == {"50": 207.69, "100": 231.56}
def test_재현기간을_바꾸면_R24_가_바뀜(project: Path) -> None:
assert site_r24(project, 100) == 231.56
assert site_r24(project, 50) == 207.69
assert site_r24(project, 30) is None
def test_자료가_없으면_등록_지역_재현기간_기본_목록(tmp_path: Path) -> None:
site = site_info(tmp_path, db_region="울진군")
assert site["region"] == {"name": "울진군", "full": "울진군", "source": "프로젝트 등록"}
assert 100 in site["periods"] and site["r24"] == {}
assert site_r24(tmp_path, 100) is None
def test_저장_파일은_B08_자기_자리(tmp_path: Path) -> None:
stations = {"85.050": {**DEFAULTS, "area_km2": 0.05}}
write_saved(tmp_path, stations)
assert (tmp_path / SAVED_FILE).is_file()
assert read_saved(tmp_path) == stations
assert [p.name for p in (tmp_path / SAVED_FILE).parent.iterdir()] == ["hydro_inputs.json"]
+75 -2
View File
@@ -4,6 +4,8 @@
② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음
③ 대피소 · 사토장 자리 — 한 자리에 둘 다
④ 고른 조각만 도면 층으로 얹힘
⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 ·
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
"""
import json
@@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
layer_id,
slope_pieces,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
_kinds_dir,
_refuge_items,
add_kind,
delete_kind,
list_kinds,
read_marks,
slope_records,
update_kind,
write_marks,
)
@@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No
longitudinal_path, longitudinal, designs = _project(tmp_path)
records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0)
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
outline, ticks = slope_pieces(route, records, 0.0, 40.0)
left_edge, left_end, right_edge, right_end = outline["lines"]
edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0)
left_edge, right_edge = edge["lines"]
# 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐
assert all(r["left_cut"] and not r["right_cut"] for r in records)
(left_end,), (right_end,) = cut["lines"], fill["lines"]
for chainage in (20, 40): # 견본 측점 둘
design = {**designs[chainage], "_c": chainage}
index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6)
@@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None:
assert layer_id("contour") not in layers and layer_id("station") not in layers
labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"]
assert "R=15" in labels and "계획평면도(시험)" in labels
def test_split_runs_switch_cut_fill_and_stay_joined() -> None:
route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)]
base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0}
records = [
{"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False},
{"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False},
]
_edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0)
(cut_left,) = cut["lines"]
fill_left = next(line for line in fill["lines"] if line[0][0] < 0)
assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐
assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0
def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
root = tmp_path / "project"
outline = {"road_edge", "fill_outline", "cut_outline"}
for (
kind
) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
folder = _kinds_dir(root)
folder.mkdir(parents=True)
(folder / "old.json").write_text(
json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8"
)
(kind,) = list_kinds(root)
assert set(kind["pieces"]) == outline | {"station"}
def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
root = tmp_path / "project"
manifest = _read_manifest(root)
manifest["drawings"] = {
"plan_terrain": {"confirmed": True},
"plan_route": {"confirmed": True},
}
_write_manifest(root, manifest)
update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침
again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음
assert again["terrain"] == {
"id": "terrain",
"name": "지형2",
"pieces": ["contour", "centerline"],
}
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
drawings = _read_manifest(root)["drawings"]
assert drawings["plan_terrain"]["confirmed"] is False
assert drawings["plan_route"]["confirmed"] is True
with pytest.raises(ValueError):
update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름
with pytest.raises(ValueError):
update_kind(root, "terrain", "지형", [])
with pytest.raises(FileNotFoundError):
update_kind(root, "nokind", "없음", ["contour"])
def test_drainage_symbol_piece() -> None:
pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}}
drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces)
mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")]
assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2
assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine)
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""공용 표 양식(PLAN 70-1) 회귀검증 — 묶음 머리 · 고정 열 · 숫자 꼴 · 빈 값 · 합계 · 1,000줄.
Node 헬퍼(`helper_ui_data_table.cjs`)가 `ui_template/ui_template_data_table.ts` 를 트랜스파일해
견본(`fixture_data_table_sample.js` · 머리 3줄 · 30열 · 66줄)으로 HTML 을 만들고, 여기서 판정함.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_ui_data_table.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_견본_크기(r):
assert r["cols"] == 30
assert r["headRows"] == 3
assert r["bodyRows"] == 66
def test_묶음_머리_합침(r):
first = r["firstHeadRow"]
# 측점 · 거리 · 유용토 등 묶음 없는 열은 머리 3줄을 세로로 차지
assert 'rowspan="3" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in first
assert first.count('rowspan="3"') == 5 # 측점 거리 유용토 차인토량 누가토량
assert 'colspan="13">절토</th>' in first # 토사 · 연암 · 측구파기 토사 · 암 각 3 + 보정량계
assert 'colspan="4">면고르기</th>' in first
assert 'colspan="2">노면정리</th>' in first
second = r["secondHeadRow"]
assert 'colspan="3">토사</th>' in second and 'colspan="3">연암</th>' in second
assert 'rowspan="2" data-num="" title="보정량계"' in second # 한 단 묶음은 머리 2줄
def test_고정_열과_숫자_칸(r):
row = r["firstBodyRow"]
assert row.startswith('<tr><td class="is-frozen is-edge" style="left:0px">120</td>')
assert r["frozenCells"] == 66
assert '<td data-num="">0</td>' in row # 거리 0 은 빈 값 아님
def test_숫자_꼴(r):
f = r["fmt"]
assert f["thousands"] == "1,234,567.89"
assert f["neg"] == "-97.90"
assert f["zero"] == "0.00"
assert f["nul"] == f["undef"] == f["blank"] == f["nan"] == "—"
assert f["text"] == "120+18" and f["textNum"] == "5"
def test_합계_줄(r):
assert r["foot"].startswith('<tr><td class="is-frozen is-edge" style="left:0px">합계</td>')
assert "is-empty" in r["foot"] # 누가토량 합계는 비움
assert r["noTotal"] is False
def test_글_새지_않음(r):
assert "&lt;b&gt;" in r["escaped"] and "a&amp;&lt;" in r["escaped"]
def test_천_줄_빠름(r):
assert r["bigRows"] == 1000
assert r["bigMs"] < 300
def test_보이는_줄만_그림(r):
w = r["win"]
assert w["n"] == 62 # 위 빈 줄 + 60줄 + 아래 빈 줄
assert 'style="height:1525px"><td colspan="30">' in w["top"] # 61줄 × 25
assert w["second"].startswith(
'<tr class="is-alt">'
) # 61 번째(홀수) 줄 — 번갈아 색은 전체 줄 번호로
assert 'style="height:21975px"' in w["bottom"] # (1000 - 121) × 25
@@ -0,0 +1,18 @@
/* 64-1 — B05 노선 추천 「종단 반영」 켬 · 끔 점수 비교
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] 열림 · 검증 프로젝트(검증_sub1)
* 부르는 법: 실행 뒤 `window.__r` 를 읽음(끝나면 채워짐)
* 기대 수치: 끔 = 균형 종단 줄 · 종단 그림 · 덮개 줄 없음 · 토공 최소 절 + 성 = 원지반 가늠 ·
* 켬 = 균형 종단 줄 있음 · 토공 최소 노선 달라짐(연장 1,156.7 → 1,153.2 m) · 공사비 어림 6,206 → 18,606
*/
(()=>{window.__r=undefined;(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const waitDone=async()=>{const t0=Date.now();await w(400);for(;;){await w(300);const s=q('[data-state]').textContent;if(s.includes('미리 보기:')||s.includes('셈하지 못')) return (Date.now()-t0)/1000;if(Date.now()-t0>280000) return -1}};
const snap=()=>({m:q('[data-line=metrics]').textContent,w:q('[data-line=metrics-work]').textContent,bal:q('[data-line=balance]').textContent,prof:!q('[data-line=profile]').hidden,cands:q('[data-line=candidates]').textContent.length});
const setBox=v=>{if(q('[data-with-profile]').checked!==v) q('[data-with-profile]').click()};
const out={};
for(const l of ['earthwork','cost','gentle']){
setBox(false); q('[data-logic='+l+']').click(); const t1=await waitDone(); out[l+'_off']={t:t1,...snap()};
setBox(true); q('[data-act=rerun]').click(); const t2=await waitDone(); out[l+'_on']={t:t2,...snap()};
}
window.__r=out;})();return 'started'})()
@@ -0,0 +1,20 @@
/* 64-2 — B05 노선 추천 창 오른쪽 설명 = 한 갈래씩 ◀ ▶ 쪽 넘김
* 시작 화면: B05 종단설계 → [계획노선 편집] → [노선 추천] (첫 미리 보기가 뜬 뒤) · 검증 프로젝트(검증_sub1)
* 기대 수치: 쪽 수 = 갈래 수 = 8 · 한 번에 한 쪽만 보임 · 처음 쪽 = 고른 갈래 · 1 / 8 에서 ◀ 꺼짐 ·
* 8 / 8 에서 ▶ 꺼짐 · 다른 칸을 만져 다시 그려도 넘겨 보던 쪽 그대로
*/
(async()=>{
const w=ms=>new Promise(r=>setTimeout(r,ms));
const q=s=>document.querySelector(s);
const help=()=>({page:q('[data-help-page]').textContent,visible:[...document.querySelectorAll('[data-help-logic]')].filter(x=>!x.hidden).map(x=>x.dataset.helpLogic),on:[...document.querySelectorAll('[data-help-logic].is-on')].map(x=>x.dataset.helpLogic),prevDis:q('[data-help-nav="-1"]').disabled,nextDis:q('[data-help-nav="1"]').disabled,total:document.querySelectorAll('[data-help-logic]').length});
const o={};
o.start=help();
for(let i=0;i<10;i++){q('[data-help-nav="-1"]').click()} o.firstPage=help();
for(let i=0;i<10;i++){q('[data-help-nav="1"]').click()} o.lastPage=help();
q('[data-help-nav="-1"]').click(); o.prev=help();
// 렌더가 돌아도 넘겨 보던 쪽 유지 — 체크 토글로 render 유발
q('[data-with-profile]').click(); await w(100); o.afterRender=help();
q('[data-with-profile]').click();
const a=q('.b05-routerec__help'); const r=a.getBoundingClientRect(); o.helpBox={w:Math.round(r.width),h:Math.round(r.height),scrollH:a.scrollHeight};
const art=[...document.querySelectorAll('[data-help-logic]')].find(x=>!x.hidden); o.artText=art.innerText.slice(0,260);
return JSON.stringify(o,null,1)})()