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

충돌 = UI_Page.ts 메뉴 블록 옆 주석(596-4) — 둘 다 살림
This commit is contained in:
2026-10-30 14:58:32 +09:00
29 changed files with 1251 additions and 317 deletions
@@ -22,7 +22,7 @@ def test_thumb_line_fits_unit_box_and_caps_points():
def test_stage_names_follow_step_bar():
assert len(summary.stage_names("ROAD")) == 7
assert summary.stage_names("PLAN")[0] == "노선 분석" # 591-2 기준 지정 걷음
assert summary.stage_names("PLAN")[0] == "예정노선" # 591-2 기준 지정 걷음
assert summary.stage_names("EROSION") == summary.stage_names("ROAD")
@@ -122,7 +122,7 @@ def test_summaries_in_batch(monkeypatch, tmp_path):
assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"]
assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2}
assert len(a["thumb"]) == 3 and a["done"] is False
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "노선 분석"
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "예정노선"
assert db["queries"] == 5
+23 -1
View File
@@ -34,7 +34,7 @@ DRAW_READS = [
("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts", '"--draw-text-secondary"'),
("B05_Profile/B05_Profile_UI_Viewer.ts", '"--draw-surface-raised"'),
("B04_PreProcess/B04_PreProcess_UI_Camera.ts", '"--draw-surface-raised"'),
("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-primary"'),
("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-map-sketch"'),
("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-warning"'),
("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-canvas"'),
("B07_DesignDetail/openwebcad/src/App.css", "--cad-canvas: var(--draw-canvas);"),
@@ -161,3 +161,25 @@ def test_changing_screen_tokens_keeps_drawing_colors():
a = resolve(value, before[mode])
assert "var(" not in a, (mode, rel, value)
assert a == resolve(value, after[mode]), (mode, rel, value)
def _lum(hex_color: str) -> float:
rgb = [int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)]
lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def test_map_drawing_colors_fixed_and_readable_on_dark_map():
"""600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1."""
blocks = _blocks(THEME.read_text(encoding="utf-8"))
names = [
k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k)
]
assert len(names) == 8, names
ground = _lum("#1f2937")
for name in names:
for mode in ("dark", "auto"):
assert name not in blocks[mode], (mode, name)
lum = _lum(blocks["light"][name])
assert (lum + 0.05) / (ground + 0.05) >= 3, name
assert (lum + 0.05) / 0.05 >= 3, name
+12 -3
View File
@@ -31,11 +31,13 @@ def test_d03_parts():
three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts")
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
draw_panel = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts")
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, tools, layers):
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, raw
# 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기)
@@ -77,7 +79,14 @@ def test_d03_parts():
("place", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
# 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수
assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2
assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel
assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw
assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel
assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6)
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
-1
View File
@@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"year-mark",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
+153
View File
@@ -0,0 +1,153 @@
"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`.
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감
(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남.
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js";
const Y = 2026;
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const p of [[100, 0], [200, 0], [300, 0]]) n = plan.appendVertex(n, 0, p);
n = plan.normalizePieces(n, Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 250), Y);
n = plan.addBranch(n, 0, 2);
n = plan.normalizePieces(plan.appendVertex(n, 1, [200, 80]), Y);
const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
const bad = span.replaceSpan(n, 0, 2, 2, []);
const straight = span.replaceSpan(n, 0, 0, 2, []);
// 599-2 노드 기록
const L = span.nodeRecord("length", 20, 9);
const S = span.nodeRecord("slope", 20, 9);
let r = plan.newLine(plan.emptyNetwork(), [0, 0]);
r = plan.appendVertex(r, 0, [10, 0]);
r = plan.appendVertex(r, 0, [30, 0], L);
r = plan.appendVertex(r, 0, [30, 5], S);
const recorded = r;
const inserted = plan.insertVertex(r, 0, 0, [5, 0]);
const dropped = plan.dropLastVertex(r, 0);
const removed = plan.removeVertex(r, 0, 1);
const reversed = plan.reverseLine(r, 0);
let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L);
const joined = plan.attachHead(t, 1);
const moved = plan.moveVertex(r, 0, 2, [33, 0]);
const flat = () => 0;
const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat));
const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]);
const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
writeFileSync(
process.argv[2],
JSON.stringify({
before: n, done, wide, bad, straight,
recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain,
}),
);
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_replace_span(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
done = r["done"]
main = done["lines"][0]
assert main["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0], [300, 0]]
assert done["lines"][1]["branch"] == {"line": "main", "vertex": 3} # 뒤 갈래 한 칸 밈
new_span = 2 * math.hypot(50, 50)
pieces = [(p["id"], p["from_m"], p["to_m"]) for p in done["pieces"] if p["line"] == "main"]
before = [p["id"] for p in r["before"]["pieces"] if p["line"] == "main"]
assert [p[0] for p in pieces] == before # 조각 id 그대로
assert pieces[1][1] == round(100 + new_span / 2, 3) # 구간 속 마크 = 길이 비율
assert pieces[2][1] == round(250 - 200 + 100 + new_span, 3) # 뒤 마크 = 길이 차
assert pieces[2][2] == round(300 - 200 + 100 + new_span, 3)
def test_blocked_and_straight(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert "갈래" in r["wide"] # 속 점(2)에 갈래
assert "고르세요" in r["bad"]
straight = r["straight"]["lines"][0]["vertices"]
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1
L = {"kind": "length", "length_m": 20, "slope_pct": None}
S = {"kind": "slope", "length_m": None, "slope_pct": 9}
F = {"kind": "free", "length_m": None, "slope_pct": None}
def _nodes(network: dict, i: int = 0) -> list:
line = network["lines"][i]
assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음
return line.get("nodes")
def test_node_records_follow_vertices(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음
assert _nodes(r["inserted"]) == [None, None, None, L, S]
assert _nodes(r["dropped"]) == [None, None, L]
assert _nodes(r["removed"]) == [None, L, S]
assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림
assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L]
assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음
# 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음
assert r["off"] == [False, True, True]
assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유
assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음
+138
View File
@@ -0,0 +1,138 @@
"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`.
같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null.
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선
NETWORK = {
"pieces": [],
"lines": [
{"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None},
{"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}},
{"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}},
{"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None},
],
}
CASES = {
"same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}],
"same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}],
"branch_main_after": [
"routeSpan",
NETWORK,
{"line": "b1", "atM": 30},
{"line": "main", "atM": 150},
],
"branch_main_before": [
"routeSpan",
NETWORK,
{"line": "b1", "atM": 30},
{"line": "main", "atM": 60},
],
"twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}],
"twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}],
"apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}],
"near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}],
"near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}],
}
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {};
for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args);
writeFileSync(process.argv[3], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("measure")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job, result = tmp / "job.json", tmp / "out.json"
job.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_같은_선은_누가_차(r):
assert r["same"]["lengthM"] == pytest.approx(110)
assert r["same"]["stations"] == [40, 150]
assert r["same"]["path"][0] == [40, 0]
assert r["same"]["path"][-1] == [100, 50]
assert r["same_back"]["lengthM"] == pytest.approx(110)
assert r["same_back"]["path"][0] == [100, 50]
def test_갈래에서_본선으로(r):
"""갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70."""
assert r["branch_main_after"]["lengthM"] == pytest.approx(80)
assert r["branch_main_after"]["stations"] is None
assert r["branch_main_after"]["path"][0] == [130, 0]
assert r["branch_main_before"]["lengthM"] == pytest.approx(70)
def test_갈래의_갈래는_고리를_따라_올라감(r):
"""b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m."""
assert r["twig_root"]["lengthM"] == pytest.approx(200)
# b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50
assert r["twig_twig"]["lengthM"] == pytest.approx(50)
def test_이어지지_않은_본선은_길_없음(r):
assert r["apart"] is None
def test_가까운_자리는_이어진_선_안에서만(r):
assert r["near_branch"]["line"] == "b1"
assert r["near_branch"]["atM"] == pytest.approx(30)
assert r["near_other_excluded"]["line"] == "main"