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

This commit is contained in:
2026-10-16 11:51:48 +09:00
17 changed files with 1040 additions and 166 deletions
+170
View File
@@ -0,0 +1,170 @@
"""343 덧 — 파생 저장본 도우미 · 유토 배분 저장본 · B07 읽기 안 지움 · 묶음 낡음 검사 기억."""
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
from uuid import uuid4
import pytest
from B06_Section import B06_Section_Router_HaulPlan as haul
from common_util import common_util_derived_store as store
from common_util import common_util_node_bundle as nb
# ── 도우미 ──────────────────────────────────────────────────────────────────────
def test_열쇠_같은_입력_같은_값() -> None:
assert store.derived_key({"a": 1, "b": 2}, 3) == store.derived_key({"b": 2, "a": 1}, 3)
assert store.derived_key({"a": 1}) != store.derived_key({"a": 2})
def test_읽기_쓰기_null_도_맞음(tmp_path: Path) -> None:
assert store.read_derived(tmp_path, "haul", "k") is None
store.write_derived(tmp_path, "haul", "k", None)
assert store.read_derived(tmp_path, "haul", "k") == {"key": "k", "value": None}
assert (tmp_path / "_derived" / "haul" / "k.json").is_file()
def test_종류마다_최근_두_벌_꺼내면_최근(tmp_path: Path) -> None:
store.write_derived(tmp_path, "haul", "a", 1)
time.sleep(0.02)
store.write_derived(tmp_path, "haul", "b", 2)
store.write_derived(tmp_path, "other", "x", 0)
time.sleep(0.02)
assert store.read_derived(tmp_path, "haul", "a")["value"] == 1
time.sleep(0.02)
store.write_derived(tmp_path, "haul", "c", 3)
names = sorted(path.stem for path in (tmp_path / "_derived" / "haul").glob("*.json"))
assert names == ["a", "c"]
assert (tmp_path / "_derived" / "other" / "x.json").is_file()
def test_결과_옆_열쇠와_파일_지문(tmp_path: Path) -> None:
path = tmp_path / "m.key.json"
assert not store.key_matches(path, {"a": 1})
path.write_text(json.dumps({"a": 1}), encoding="utf-8")
assert store.key_matches(path, {"a": 1}) and not store.key_matches(path, {"a": 2})
assert store.file_stamp(tmp_path / "없음") is None and store.file_stamp(path)
# ── 유토 배분 디스크 저장본 ─────────────────────────────────────────────────────────
def _wire(monkeypatch: pytest.MonkeyPatch, root: Path, outputs: list) -> list:
calls: list = []
async def _none(_project_id): # type: ignore[no-untyped-def]
return {}
async def _stored(_fn, _project_id): # type: ignore[no-untyped-def]
return "p"
def _run(_bundle, _script, payload): # type: ignore[no-untyped-def]
calls.append(payload)
return outputs.pop(0)
monkeypatch.setattr(haul, "haul_inputs_for", _none)
monkeypatch.setattr(haul, "conversion_factors_for", _none)
monkeypatch.setattr(haul, "haul_limits_for", _none)
monkeypatch.setattr(haul, "_mass_haul_context", lambda *_: {"limits": []})
monkeypatch.setattr(haul, "run_bundle_json", _run)
monkeypatch.setattr(haul, "run_with_connection", _stored)
monkeypatch.setattr(haul, "resolve_stored_project_path", lambda _p: str(root))
haul._KEPT.clear()
return calls
def _post(result: dict) -> dict:
response = asyncio.run(haul.compute_haul_plan(uuid4(), 7, {"result": result}))
return json.loads(response.body)
def test_유토_배분_재시작_뒤에도_셈_없음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls = _wire(monkeypatch, tmp_path, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}])
a = {"points": [{"cumulative_volume_m3": 1.0}]}
assert _post(a)["haul_plan"] == {"tag": "a"} and len(calls) == 1
key = haul._plan_key(a, {"limits": []}) # 화면 · 서버 메모리와 같은 열쇠
assert (tmp_path / "_derived" / "haul" / f"{key}.json").is_file()
haul._KEPT.clear() # 재시작
assert _post(a)["haul_plan"] == {"tag": "a"} and len(calls) == 1
b = {"points": [{"cumulative_volume_m3": 2.0}]}
assert _post(b)["haul_plan"] == {"tag": "b"} and len(calls) == 2
def test_유토_배분_저장소_못_찾아도_셈(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls = _wire(monkeypatch, tmp_path, [{"haul_plan": {"tag": "a"}}])
async def _missing(_fn, _project_id): # type: ignore[no-untyped-def]
raise LookupError("없음")
monkeypatch.setattr(haul, "run_with_connection", _missing)
assert _post({"points": [{"cumulative_volume_m3": 1.0}]})["haul_plan"] == {"tag": "a"}
assert len(calls) == 1 and not (tmp_path / "_derived").exists()
# ── B07 읽기는 측점 파일을 안 지움 ─────────────────────────────────────────────────
def test_B07_읽기는_잔재를_걸러만_읽음(tmp_path: Path) -> None:
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _cross_files
cross_dir = tmp_path / "B05_Profile" / "cross_sections"
cross_dir.mkdir(parents=True)
for name in ("cross_00000m.json", "cross_00020m.json", "cross_00040m.json"):
(cross_dir / name).write_text("{}", encoding="utf-8")
longitudinal_path = tmp_path / "B05_Profile" / "longitudinal" / "longitudinal.json"
files = _cross_files(longitudinal_path, {"stations": [{"chainage_m": 0}, {"chainage_m": 20}]})
assert [path.name for path in files] == ["cross_00000m.json", "cross_00020m.json"]
assert (cross_dir / "cross_00040m.json").is_file()
# ── 묶음 낡음 검사 기억 ─────────────────────────────────────────────────────────
@pytest.fixture
def bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(nb, "ROOT", tmp_path)
monkeypatch.setattr(nb, "_FRESH", {})
path = tmp_path / "dist" / "bundle.mjs"
path.parent.mkdir()
path.write_text("", encoding="utf-8")
os.utime(path, (1_000_000_000, 1_000_000_000))
return path
def _counting(monkeypatch: pytest.MonkeyPatch) -> list:
scans: list = []
real = nb._sources_newer
def counting(built_at: float) -> bool:
scans.append(built_at)
return real(built_at)
monkeypatch.setattr(nb, "_sources_newer", counting)
return scans
def test_낡지_않음은_잠시_기억(bundle: Path, monkeypatch: pytest.MonkeyPatch) -> None:
scans = _counting(monkeypatch)
assert not nb.bundle_stale(bundle) and not nb.bundle_stale(bundle)
assert len(scans) == 1
# 묶음을 새로 지으면(mtime 바뀜) 다시 잼
os.utime(bundle, (1_000_000_100, 1_000_000_100))
assert not nb.bundle_stale(bundle) and len(scans) == 2
# 기억 시간이 지나면 다시 잼
monkeypatch.setattr(nb, "RECHECK_S", 0.0)
assert not nb.bundle_stale(bundle) and len(scans) == 3
def test_낡음은_기억하지_않음(bundle: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = nb.ROOT / "B05_Profile" / "x.ts"
source.parent.mkdir()
source.write_text("", encoding="utf-8")
scans = _counting(monkeypatch)
assert nb.bundle_stale(bundle) and nb.bundle_stale(bundle)
assert len(scans) == 2 and str(bundle) not in nb._FRESH
@@ -0,0 +1,128 @@
"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기.
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_profile_history.py -q
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
DRIVER = r"""
import { writeFileSync } from "node:fs";
// 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고).
const data = new Map();
globalThis.sessionStorage = {
get length() { return data.size; },
key: (i) => [...data.keys()][i] ?? null,
getItem: (k) => (data.has(k) ? Buffer.from(data.get(k)).toString() : null),
setItem: (k, v) => data.set(k, String(v)),
removeItem: (k) => data.delete(k),
};
globalThis.window = new EventTarget();
window.setTimeout = setTimeout;
const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js");
const tick = () => new Promise((r) => setTimeout(r, 5));
const out = {};
// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌
const K = {
route: "aislo:draft:route-draft:p1",
cross: "aislo:draft:crossdesign:p1:7",
pipes: "aislo:draft:pipes:p1",
legacy: "b05:uphill:p1",
layout: "b05-panel-collapsed",
pref: "aislo:pref:table-open",
result: "aislo:result:latest:p1",
};
for (const k of Object.values(K)) sessionStorage.setItem(k, "0");
let restored = 0;
const h = createProfileHistory(() => (restored += 1));
for (const k of Object.values(K)) sessionStorage.setItem(k, "1");
h.record();
out.undo = h.undo();
out.after = Object.fromEntries(Object.entries(K).map(([n, k]) => [n, sessionStorage.getItem(k)]));
out.restored = restored;
// 2. 누르고 있는 동안 기록 미룸 · 놓으면 한 번
data.clear();
sessionStorage.setItem(K.cross, "a");
const h2 = createProfileHistory(() => {});
window.dispatchEvent(new Event("pointerdown"));
for (const v of ["b", "c", "d", "e"]) {
sessionStorage.setItem(K.cross, v);
h2.record();
}
out.heldCanUndo = h2.canUndo();
window.dispatchEvent(new Event("pointerup"));
await tick();
out.releasedCanUndo = h2.canUndo();
h2.undo();
out.oneUndo = sessionStorage.getItem(K.cross);
out.noMoreUndo = !h2.canUndo();
// 3. 크기 — 300 KB 칸 셋 · 작은 칸만 바꾸며 100단
data.clear();
const big = (c) => c.repeat(300 * 1024);
sessionStorage.setItem(K.route, big("r"));
sessionStorage.setItem(K.cross, big("x"));
sessionStorage.setItem(K.pipes, big("p"));
globalThis.gc();
const before = process.memoryUsage().heapUsed;
const h3 = createProfileHistory(() => {});
for (let i = 0; i < 100; i += 1) {
sessionStorage.setItem("aislo:draft:alignment-draft:p1:7", JSON.stringify({ i }));
h3.record();
}
globalThis.gc();
out.heapMb = (process.memoryUsage().heapUsed - before) / 1024 / 1024;
let steps = 0;
while (h3.undo()) steps += 1;
out.steps = steps;
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def result(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("ts_history")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
"--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads((out / "out.json").read_text(encoding="utf-8"))
def test_초안_통_전부를_되돌림(result) -> None:
after = result["after"]
assert result["undo"] and result["restored"] == 1
assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0"
# 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림
assert after["layout"] == after["pref"] == after["result"] == "1"
def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None:
assert result["heldCanUndo"] is False
assert result["releasedCanUndo"] is True
assert result["oneUndo"] == "a" and result["noMoreUndo"]
def test_큰_칸_100단도_바뀐_칸만_늚(result) -> None:
assert result["steps"] == 99 # 한도 100(첫 사진 포함)
# 같은 글을 같이 쓰지 않으면 900 KB × 100 ≈ 90 MB(UTF-8 1바이트 글 기준 · 실제는 더)
assert result["heapMb"] < 10, result["heapMb"]
@@ -0,0 +1,169 @@
"""347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보.
- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 작업본에 안 씀.
- 메모리 격자로 세부 유역(파일 안 읽음) · 노선 초안 → 그린 노선.
- 관 자리 후보: 최대 간격(100 m)을 넘는 구간에만 · 기존 관 자리엔 안 냄 · 관 목록엔 안 넣음.
- [확정](recompute) = 낡았으면 배수유역부터 다시 셈 · [저장] = 안 셈.
"""
import asyncio
import math
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
import pytest
from shapely.geometry import LineString
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B04_PreProcess import B04_PreProcess_Router_Watershed_Live as live
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_context import DrainageContext
from common_util.common_util_drainage_detail import (
RoadRouting,
build_detail,
pipes_from_chainages,
suggest_pipe_positions,
)
from common_util.common_util_route_geometry import RouteVertex
VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)]
def test_draft_vertices_follow_nodes():
draft = {"nodes": [[0, 0], [100, 0], [100, 100]], "curveOn": [False, False, False]}
vertices = live.draft_vertices(draft, 30.0)
assert vertices[0].x == 0 and vertices[-1].y == 100
assert math.isclose(vertices[-1].chainage_m, 200.0)
def test_candidates_only_in_long_gaps():
strength = np.zeros(301)
strength[150] = 50.0 # 흐름이 모이는 자리
pipes = pipes_from_chainages(VERTICES, [0.0, 300.0])
picked = suggest_pipe_positions(VERTICES, pipes, strength)
assert picked and all(0 < pipe.chainage_m < 300 for pipe in picked)
assert all(pipe.chainage_m == round(pipe.chainage_m) for pipe in picked) # 정수 m
dense = pipes_from_chainages(VERTICES, [0.0, 90.0, 180.0, 270.0, 300.0])
assert suggest_pipe_positions(VERTICES, dense, strength) == []
def _routing() -> RoadRouting:
spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310)
road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32)
road_slot = np.full(spec.size, -1, dtype=np.int32)
road_slot[road_index] = np.arange(road_index.size)
return RoadRouting(
spec=spec,
road_slot=road_slot,
path_length=np.zeros(spec.size, np.float32),
elevation=np.full(spec.size, 95.0, np.float32),
road_cell_index=road_index,
road_chainage=np.arange(road_index.size, dtype=np.float64),
strength=np.ones(road_index.size, np.int64),
)
def test_build_detail_uses_memory_routing(tmp_path):
missing = tmp_path / "없는 폴더" # 파일을 읽으면 None 이 됨
detail = build_detail(missing, VERTICES, [0.0, 300.0], _routing(), [])
assert detail is not None and detail.pipe_candidates
assert build_detail(missing, VERTICES, [0.0, 300.0]) is None
def _patch_live(monkeypatch, preview_fn):
async def prepare(project_id):
return {"contours": [], "streams": [], "contour_key": "k"}
async def load(project_id):
return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), ""
monkeypatch.setattr(live, "_prepare", prepare)
monkeypatch.setattr(live, "load_drainage_context", load)
monkeypatch.setattr(live, "drainage_dir", lambda stored: Path("."))
monkeypatch.setattr(live, "preview_stages", preview_fn)
calls = SimpleNamespace(build=0)
def build(*args):
calls.build += 1
return None, []
monkeypatch.setattr(live, "_build", build)
return calls
def test_newer_generation_stops_between_stages(monkeypatch):
pid = uuid4()
def preview(vertices, contours, streams, store, should_stop):
live._LATEST_GENERATION[str(pid)] = 8 # 계산 중 새 조작 → 새 요청이 차례를 올림
if should_stop():
raise expand.AnalysisStopped
calls = _patch_live(monkeypatch, preview)
result = asyncio.run(live.post_drainage_live(pid, {"generation": 7, "points": []}))
assert result == live.SUPERSEDED and calls.build == 0
def test_newer_generation_after_preview_skips_split(monkeypatch):
pid = uuid4()
def preview(vertices, contours, streams, store, should_stop):
assert not should_stop()
live._LATEST_GENERATION[str(pid)] = 3
return SimpleNamespace(routing=object())
calls = _patch_live(monkeypatch, preview)
result = asyncio.run(live.post_drainage_live(pid, {"generation": 2}))
assert result == live.SUPERSEDED and calls.build == 0
def test_engine_stops_in_expansion_round():
contours = [
{
"geometry": {
"type": "LineString",
"coordinates": [
[150 + r * math.cos(t), -150 + r * math.sin(t)]
for t in np.linspace(0, 2 * math.pi, 121)
],
},
"properties": {"elevation": 300 - r},
}
for r in range(10, 200, 10)
]
spec = GridSpec(x_min=100.0, y_max=-100.0, cell_m=1.0, n_rows=100, n_cols=100)
domain = np.zeros((100, 100), bool)
domain[20:80, 20:80] = True
route = LineString([(110.0, -150.0), (190.0, -150.0)])
with pytest.raises(expand.AnalysisStopped):
expand.expand_by_red_boundary(
spec, domain, contours, route, [], None, should_stop=lambda: True
)
@pytest.mark.parametrize(
"recompute, stale, expected", [(True, True, 1), (True, False, 0), (False, True, 0)]
)
def test_confirm_recomputes_only_when_stale(monkeypatch, recompute, stale, expected):
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
calls = []
async def is_stale(project_id):
return stale
async def region(project_id, refresh=False):
calls.append(refresh)
return {"status": "success"}
async def resolve(*args, **kwargs):
return basins.JSONResponse(status_code=404, content={})
monkeypatch.setattr(basins, "_analysis_stale", is_stale)
monkeypatch.setattr(watershed, "get_primary_region", region)
monkeypatch.setattr(basins, "_resolve", resolve)
asyncio.run(basins.put_pipe_points(uuid4(), {"points": []}, None, recompute))
assert len(calls) == expected and all(calls)