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

This commit is contained in:
2026-10-30 14:44:50 +09:00
35 changed files with 1599 additions and 347 deletions
+29 -2
View File
@@ -3,7 +3,8 @@
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names` · 붙인 이름 594-1) ·
이름 규칙 거울(`nameError` ↔ `name_error` · 594-1).
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
"""
@@ -101,12 +102,22 @@ const names = [
plan.lineNames([{ branch: null }]),
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
plan.lineNames([
{ branch: null, name: " 임도 A " },
{ branch: null },
{ branch: { line: "main", vertex: 1 }, name: "" },
]),
];
const B = String.fromCharCode(92);
const NAME_CASES = ["가".repeat(20), "가".repeat(21), " 임도 ", "", "a/b", "a" + B + "b",
"a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "a" + String.fromCharCode(9) + "b",
"용화-1_(2027)"];
const nameErrors = NAME_CASES.map((name) => [name, plan.nameError(name)]);
writeFileSync(process.argv[2], JSON.stringify({
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
marks: plan.marksOf(branched),
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
reversed, branchFlip, lineYear, markMoved, markClamp, names,
reversed, branchFlip, lineYear, markMoved, markClamp, names, nameErrors, NAME_MAX: plan.NAME_MAX,
}));
"""
@@ -260,7 +271,23 @@ def test_line_names_mirror(tmp_path: Path) -> None:
[{"branch": None}],
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
[
{"branch": None, "name": " 임도 A "},
{"branch": None},
{"branch": {"line": "main", "vertex": 1}, "name": ""},
],
]
assert got["names"] == [line_names(c) for c in cases]
assert got["names"][3] == ["임도 A", "본선 2", "갈래 1"] # 붙인 이름 · 빈 이름 = 차례 이름
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
def test_name_rule_mirror(tmp_path: Path) -> None:
"""594-1 이름 규칙 — TS `nameError` ↔ 서버 `name_error` 같은 판정 · 20 자 · 금지 글자."""
got = _run_ts(tmp_path)
assert got["NAME_MAX"] == server.NAME_MAX == 20
for name, error in got["nameErrors"]:
assert (error is None) == (server.name_error(name) is None), name
ok = {name for name, error in got["nameErrors"] if error is None}
assert ok == {"가" * 20, " 임도 ", "", "용화-1_(2027)"}
+212
View File
@@ -0,0 +1,212 @@
"""D03 예상 횡단(PLAN 544-1 · 601-1) — TS `logic/D03_PlanAnalysis_Expect.ts` 값 = 파이썬 `compute_cross_design`.
기본 = 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구 · 암 경계 = config 기본 · 계획고 = 측점 중심 지반고.
미교차 = 격자로 넓힌 표본 · 넓힌 표본으로 파이썬이 다시 셈한 값과 같음 · 코리도 측점 꼴.
노선망 3D(601) = 선마다 측점 · 갈래 머리 = 리핑암 차도 반 + 노견만큼 잘라 냄.
./venv/Scripts/python.exe -m pytest resources/tester/test_544_1_plan_expect.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system_design import ( # noqa: E402
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Expect.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as expect from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.js";
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
const std = { standard: job.standard, rockBoundaryM: job.rock };
// 평면 격자 — z = 100 + slope * y(좌 축 = +y) · 1 m · ±100 m
const n = 201;
const axis = Float64Array.from({ length: n }, (_, i) => i - 100);
const z = new Float64Array(n * n);
for (let iy = 0; iy < n; iy += 1)
for (let ix = 0; ix < n; ix += 1) z[iy * n + ix] = 100 + job.wideSlope * axis[iy];
const grid = { x: axis, y: axis, z, validBits: new Uint8Array(Math.ceil((n * n) / 8)).fill(255) };
const cross = job.stations.map((s) => expect.expectCross(s, std));
const wide = expect.expectCross(job.wide, std, grid);
const sections = expect.expectSections([...job.stations, job.wide], std, grid);
const trim = expect.branchTrimM(std);
const lines = [
expect.expectLine([[0, 0], [50, 0]], 0, 20),
expect.expectLine([[0, 0], [30, 0], [30, 20]], trim, 20),
expect.expectLine([[0, 0], [1.5, 0]], trim, 20),
];
writeFileSync(process.argv[3], JSON.stringify({ cross, wide, sections, trim, lines }));
"""
def _station(slope: float, uphill: str | None, *, half: float = 20.0) -> dict:
samples = []
count = int(half * 2 / 0.5) + 1
for i in range(count):
offset = -half + i * 0.5
z = round(100.0 + slope * offset, 6)
samples.append(
{"offset_m": offset, "x": 0.0, "y": offset, "z": z, "elevation_m": z, "valid": True}
)
return {
"chainage_m": 40.0,
"center_x": 0.0,
"center_y": 0.0,
"center_z": 100.0,
"tangent_xy": [1.0, 0.0],
"left_xy": [0.0, 1.0],
"uphill_side": uphill,
"samples": samples,
}
_STATIONS = [
_station(0.30, "left"),
_station(-0.45, "right"),
_station(0.05, None),
{**_station(0.2, "left"), "center_z": None},
]
_WIDE_SLOPE = 0.8
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")
job = tmp_path / "job.json"
result = tmp_path / "out.json"
job.write_text(
json.dumps(
{
"standard": STANDARD_CROSS_SECTION,
"rock": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
"stations": _STATIONS,
"wide": _station(_WIDE_SLOPE, "left"),
"wideSlope": _WIDE_SLOPE,
}
),
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 _python(samples: list[dict], elevation: float, uphill: str | None) -> dict:
return compute_cross_design(
samples,
elevation,
ground_type="ripping_rock",
section_mode=f"{uphill or 'left'}_cut",
ditch_type="standard",
standard=STANDARD_CROSS_SECTION,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
def _same(ts: dict, py: dict) -> None:
assert ts["section_mode"] == py["section_mode"]
assert ts["ground_type"] == "ripping_rock" and ts["ditch_type"] == "standard"
assert abs(ts["cut_area_m2"] - py["cut_area_m2"]) < 1e-9
assert abs(ts["fill_area_m2"] - py["fill_area_m2"]) < 1e-9
assert len(ts["design_line"]) == len(py["design_line"])
for a, b in zip(ts["design_line"], py["design_line"]):
assert abs(a["offset_m"] - b["offset_m"]) < 1e-9
assert abs(a["elevation_m"] - b["elevation_m"]) < 1e-9
def test_expect_matches_python(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
for station, ts in zip(_STATIONS, got["cross"]):
if station["center_z"] is None:
assert ts is None # 지반고 없음 = 못 세움
continue
py = _python(station["samples"], station["center_z"], station["uphill_side"])
_same(ts["design"], py)
assert ts["design"]["design_elevation_m"] == station["center_z"]
# 산쪽 절토 · 모르면 좌
assert [c and c["design"]["section_mode"] for c in got["cross"]] == [
"left_cut",
"right_cut",
"left_cut",
None,
]
# 비탈 표본 — 절토 · 성토 둘 다 섬
assert got["cross"][0]["design"]["cut_area_m2"] > 0
assert got["cross"][0]["design"]["fill_area_m2"] > 0
def test_unclosed_extends_and_sections(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
wide = got["wide"]
narrow = _python(_station(_WIDE_SLOPE, "left")["samples"], 100.0, "left")
assert narrow["slope_unclosed"] is True # ±20 m 안에선 안 닫힘
assert wide["design"]["slope_unclosed"] is False
assert min(s["offset_m"] for s in wide["samples"]) < -20.0
_same(wide["design"], _python(wide["samples"], 100.0, "left"))
# 코리도 측점 = 세운 측점만(지반고 없는 측점 뺌) · 좌 축 · 설계 실음
sections = got["sections"]
assert len(sections) == 4
assert all(s["frame"]["left_xy"] == [0.0, 1.0] and s["design"]["design_line"] for s in sections)
assert sections[-1]["samples"] == wide["samples"]
def test_network_lines_trim_branch_head(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rock = STANDARD_CROSS_SECTION["rock"]
trim = rock["road_width_m"] / 2 + max(rock["shoulder_left_m"], rock["shoulder_right_m"])
assert abs(got["trim"] - trim) < 1e-12 # 3.0 / 2 + 0.5 = 2.0
main, branch, short = got["lines"]
# 본선 — 그대로 · 측점 0 · 20 · 40 · 끝 50
assert main["offsetM"] == 0 and main["line"] == [[0, 0], [50, 0]]
assert main["stations"] == [0, 20, 40, 50]
# 갈래 — 머리 2 m 잘라 냄 · 원래 누가 20 · 40 · 50 → 18 · 38 · 48 · 꺾임점 남음
assert branch["offsetM"] == trim
assert branch["line"][0] == [trim, 0] and branch["line"][-1] == [30, 20]
assert [30, 0] in branch["line"]
assert [round(m + trim, 9) for m in branch["stations"]] == [trim, 20, 40, 50]
# 남는 길이 1 m 안 됨 = 못 그림
assert short is None
+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 -4
View File
@@ -30,12 +30,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(" 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,12 +78,19 @@ 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
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
@@ -3,6 +3,7 @@
견본 = 같은 연도 조각 둘(본선 p1 · 갈래 p3, 2026) + 본선 p2(2027) · 업체 둘(p1 = 회사 5 · p3 = 회사 6).
- D04 요약 = 조각 줄 · 이름 「본선 0+0~12+10」 · 지정 회사 = 자기 조각 · 자기 행만
- 594-2 붙인 선 이름 = 조각 이름표 · zip 폴더 · 좌표표 xlsx 「선」 칸
- 내보내기 = 조각마다 폴더 · 전체 = 선마다 레코드 · 지정 회사 = 확정된 자기 조각만
- 확정 메일 = 업체마다 자기 조각 zip(남의 조각 안 샘)
- 업체 저장 = 조각 id 검사 · 연도는 조각에서 · 같은 업체 두 조각 = 두 행
@@ -21,6 +22,7 @@ from pathlib import Path
from uuid import uuid4
import geopandas as gpd
import openpyxl
import pytest
from fastapi import HTTPException
@@ -280,3 +282,27 @@ def test_assigned_list_piece_label_length(plan, monkeypatch):
assert [(r["piece_id"], r["year"], r["piece_label"], r["length_m"]) for r in out] == [
("p3", 2026, "갈래 1 0+0~6+10", 130.0),
]
def test_line_name_flows_to_labels_zip_xlsx(plan):
"""594-2 붙인 선 이름 = 조각 이름표 · zip 폴더 · 좌표표 xlsx 「선」 칸(빈칸 뺌)."""
named = [{**LINES[0], "name": "용화 본선"}, LINES[1]]
plan["file"]["plan"] = plan_of(named, MARKS, PIECES)
rows = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))["pieces"]
assert [r["label"] for r in rows] == [
"용화 본선 0+0~12+10",
"갈래 1 0+0~6+10",
"용화 본선 12+10~25+19.3",
]
body = asyncio.run(d04.export_plan(PLAN_ID, FOREST)).body
with zipfile.ZipFile(io.BytesIO(body)) as z:
assert {n.split("/")[0] for n in z.namelist()} == {
"2026_용화본선_p1",
"2026_갈래1_p3",
"2027_용화본선_p2",
"전체",
}
book = next(n for n in z.namelist() if n.startswith("전체/") and n.endswith(".xlsx"))
sheet = openpyxl.load_workbook(io.BytesIO(z.read(book))).active
lines = [row[14] for row in sheet.iter_rows(min_row=3, values_only=True)]
assert sorted(lines) == ["갈래1", "용화본선", "용화본선"]
-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)
@@ -6,6 +6,8 @@
- 옛 지점 칸(시점 · 종점 목록 · 한 점 꼴)은 열 때 옮겨 보임.
- 핀 끌기 = 그 자리 노선 노드 같이(TS `moveNodesAt` · 붙은 갈래 첫 점 · 다른 점은 그대로).
- D02 길 · 단계 · 폴더가 남지 않음.
- 594-1 지점 · 선 이름 · 노드 기록 왕복 · 옛 파일 읽힘 · 21 자 · 금지 글자 400 ·
594-3 [저장] 몸 기준점 → 등록 칸(바뀔 때만 · 주소 = 서버 다시 찾음).
./venv/Scripts/python.exe -m pytest resources/tester/test_591_2_plan_points.py -q
"""
@@ -158,6 +160,103 @@ def test_epsg_stays_once_saved(api) -> None:
assert got.status_code == 400 # 파일 머리 좌표계 한 곳
# --- 594-1 지점 · 선 이름 · 노드 기록 ---
NODES = [None, {"kind": "both", "length_m": 20.0, "slope_pct": 8.5}]
def test_names_nodes_round_trip(api) -> None:
# 옛 파일(이름 · 기록 칸 없음) 그대로 읽힘
store.save_plan(
api.state["root"],
{**store.empty_plan(5186), "points": [{"x": A[0], "y": A[1]}], "lines": [LINE]},
)
old = api.get(URL).json()
assert "name" not in old["base"]["points"][0] and "name" not in old["network"]["lines"][0]
line = {**LINE, "name": " 용화 본선 ", "nodes": NODES}
branch = {
"id": "branch-1",
"vertices": [list(B), [B[0] + 50, B[1]]],
"branch": {"line": "main", "vertex": 1},
}
body = {
"epsg": 5186,
"points": [list(A), list(B)],
"point_names": ["시점", None],
"lines": [line, branch],
}
got = api.put(URL, json=body)
assert got.status_code == 200, got.text
view = api.get(URL).json()
assert [p.get("name") for p in view["base"]["points"]] == ["시점", None]
main, side = view["network"]["lines"]
assert main["name"] == "용화 본선" and main["nodes"] == NODES
assert "name" not in side and "nodes" not in side # 빈 칸 = 옛 꼴 그대로
saved = _saved(api)
assert saved["points"][1] == {"x": B[0], "y": B[1]}
# 이름만 다시 [저장] = 같은 값
again = api.put(URL, json={**body, "lines": view["network"]["lines"]}).json()
assert again["network"]["lines"] == view["network"]["lines"]
@pytest.mark.parametrize(
"patch",
[
{"lines": [{**LINE, "name": "가" * 21}]},
{"lines": [{**LINE, "name": "a/b"}]},
{"lines": [{**LINE, "name": 'a"b'}]},
{"point_names": ["시:점"]},
{"point_names": ["가" * 21]},
{"point_names": ["a", "b"]}, # 지점보다 많음
{"lines": [{**LINE, "nodes": [None]}]}, # 꼭짓점 둘 · 기록 하나
],
)
def test_bad_names_nodes_400(api, patch) -> None:
body = {"epsg": 5186, "points": [list(A)], "lines": [LINE], **patch}
assert api.put(URL, json=body).status_code == 400
assert not store.plan_path(api.state["root"]).exists() # 안 씀
def test_name_20_ok_and_bad_kind_422(api) -> None:
body = {"epsg": 5186, "points": [list(A)], "point_names": ["가" * 20], "lines": [LINE]}
assert api.put(URL, json=body).status_code == 200
bad = {**LINE, "nodes": [None, {"kind": "grade", "length_m": 1, "slope_pct": 1}]}
assert api.put(URL, json={"epsg": 5186, "lines": [bad]}).status_code == 422
# --- 594-3 [저장] 몸 기준점 → 등록 칸 ---
def test_site_saved_with_network(api, monkeypatch) -> None:
written = []
async def store_site(connection, project_id, site):
written.append(site)
async def place(lon, lat):
return {"address": f"주소 {lon:.3f}", "region": "어딘가"}
monkeypatch.setattr(net, "_store_site", store_site)
monkeypatch.setattr(net, "reverse_place", place)
same = {"lon": SITE["lon"], "lat": SITE["lat"]}
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": same})
api.put(URL, json={"epsg": 5186, "lines": [LINE]})
assert written == [] # 그대로 · 빈 값 = 안 씀
moved = {"lon": 127.2, "lat": 37.6, "address": "몸 주소"}
got = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": moved}).json()
assert written == [{"lon": 127.2, "lat": 37.6, "address": "주소 127.200"}] # 서버가 다시 찾음
assert got["base"]["site"] == written[0]
async def no_place(lon, lat):
return {"address": None, "region": None}
monkeypatch.setattr(net, "reverse_place", no_place)
api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {**moved, "lon": 127.3}})
assert written[-1]["address"] == "몸 주소" # 못 찾으면 몸 주소
bad = api.put(URL, json={"epsg": 5186, "lines": [LINE], "site": {"lon": 200, "lat": 37}})
assert bad.status_code == 422
# --- D02 걷음 ---
+82
View File
@@ -0,0 +1,82 @@
"""594-4 공용 주소 · 장소 찾기 길(`GET /api/map/search`) — VWorld 가짜 응답.
- 주소(도로명 → 지번) 먼저 · 장소 뒤 · 같은 이름 · 주소 줄 하나 · 키는 서버(응답에 없음).
- 빈 값 = 빈 목록 · 100 자 넘음 = 422 · VWorld 실패 = 빈 목록.
./venv/Scripts/python.exe -m pytest resources/tester/test_594_4_map_search.py -q
"""
from __future__ import annotations
import json
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B01_Dashboard import B01_Dashboard_Map as vmap
from common_util import common_util_map_router as map_router
ADDRESS = {
"address": {"zipcode": "36509", "road": "", "parcel": "경상북도 영양군 일월면 용화리 1"},
"point": {"x": "129.165", "y": "36.815"},
}
PLACE = {
"title": "용화보건진료소",
"address": {"road": "경상북도 영양군 일월면 영양로 3773", "parcel": "… 용화리 225-1"},
"point": {"x": "129.150", "y": "36.789"},
}
@pytest.fixture
def api(monkeypatch):
asked = []
def fetch(url, timeout=5):
query = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
asked.append(query)
if query.get("query") == "고장":
raise OSError("down")
if query["type"] == "address":
items = [ADDRESS] if query["category"] == "parcel" else []
else:
items = [PLACE, PLACE] # VWorld 가 같은 장소를 두 번 줌
return json.dumps({"response": {"status": "OK", "result": {"items": items}}}).encode()
monkeypatch.setattr(vmap, "_fetch", fetch)
app = FastAPI()
app.include_router(map_router.router)
client = TestClient(app)
client.asked = asked
return client
def test_address_then_place(api) -> None:
got = api.get("/api/map/search", params={"q": " 용화리 "})
assert got.status_code == 200
items = got.json()["items"]
assert items == [
{
"kind": "address",
"title": "경상북도 영양군 일월면 용화리 1",
"address": "",
"lon": 129.165,
"lat": 36.815,
},
{
"kind": "place",
"title": "용화보건진료소",
"address": "경상북도 영양군 일월면 영양로 3773",
"lon": 129.15,
"lat": 36.789,
},
]
assert {q["query"] for q in api.asked} == {"용화리"} # 앞뒤 빈칸 뺌
assert "key" not in got.text # 키는 서버만
def test_empty_long_and_failure(api) -> None:
assert api.get("/api/map/search", params={"q": " "}).json()["items"] == []
assert api.get("/api/map/search", params={"q": "가" * 101}).status_code == 422
assert api.get("/api/map/search", params={"q": "고장"}).json()["items"] == []