Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -46,6 +46,14 @@ async function run() {
|
||||
saveSections: async (...args) => (sent.push(args.map((a) => a ?? null)), {}),
|
||||
fetchSectionDetail: async () => null,
|
||||
},
|
||||
// 445 H5 — 노선 저장 뒤 본문 = 횡단 보기와 같은 칸(세션 고침 · 표준 횡단면 · 풍선)
|
||||
"./B06_Section_Cross_Design_Session": { crossDesignChoices: () => new Map() },
|
||||
"./B06_Section_UI_Page_Persist": {
|
||||
readRockBoundarySession: () => ({}),
|
||||
readCutSlopeSession: () => ({}),
|
||||
},
|
||||
"./logic/B06_Section_UI_Page_Patches": { mergeCrossPatches: (a, b) => [...a, ...b] },
|
||||
"@util/common_util_mass_haul_balance_view": { balloonOffsetsPayload: () => ({}) },
|
||||
})(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
|
||||
out.noPatchReturn = await store.saveCachedCrossPatches("p1", 9);
|
||||
out.noPatchSent = sent;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/* PLAN 445 헬퍼 — [저장] 횡단 patch 바탕 · 세션 얹음 · 되돌림 · 옮긴 측점 입력.
|
||||
* 조정창(`createStationControls`)으로 고친 뒤 횡단 보기 [저장] 본문(`buildCrossPatches` → `cachedCrossPatches`)을
|
||||
* 그대로 만들어 내놓음 — 시험이 서버 병합(`_apply_section_edits` → `merge_cross_section_design_batches`)에 넣어
|
||||
* DB 설계를 견줌. TS 는 그 자리에서 트랜스파일 · 화면 · 서버 창구는 가짜. 짝 = `test_445_save_patch.py`.
|
||||
* node helper_445_save_patch.cjs <job.json>
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
|
||||
const memory = () => {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k),
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
globalThis.sessionStorage = memory();
|
||||
globalThis.localStorage = memory();
|
||||
globalThis.window = globalThis;
|
||||
|
||||
const anything = () =>
|
||||
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
|
||||
|
||||
function makeLoader(real, stubs = {}) {
|
||||
const cache = new Map();
|
||||
const realSet = new Set(real.map((file) => path.resolve(file)));
|
||||
const load = (file) => {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const need = (spec) => {
|
||||
if (spec in stubs) return stubs[spec];
|
||||
let target = null;
|
||||
if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`);
|
||||
else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`);
|
||||
if (target && realSet.has(path.resolve(target))) return load(target);
|
||||
return anything();
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
};
|
||||
return load;
|
||||
}
|
||||
|
||||
const bodyStub = () => ({
|
||||
control: { selectedFor: () => false, select: () => undefined },
|
||||
byChainage: () => new Map(),
|
||||
load: () => undefined,
|
||||
});
|
||||
const load = makeLoader(
|
||||
[
|
||||
B06("B06_Section_Section_Store"),
|
||||
B06("B06_Section_Section_Canon"),
|
||||
B06("B06_Section_Section_Adjusts"),
|
||||
B06("B06_Section_Cross_Refresh"),
|
||||
B06("B06_Section_Api_Culvert_Options"),
|
||||
B06("B06_Section_UI_Page_Station_Controls"),
|
||||
B06("B06_Section_UI_Page_Link_Session"),
|
||||
B06("B06_Section_UI_Cross_Culvert_Types"),
|
||||
B06("logic/B06_Section_Station_Swap"),
|
||||
B06("logic/B06_Section_UI_Page_Patches"),
|
||||
path.join(ROOT, "A00_Common", "b_page_state.ts"),
|
||||
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
|
||||
],
|
||||
{
|
||||
"./B06_Section_Api_Fetch": {
|
||||
fetchSectionDetail: async () => job.detail,
|
||||
saveSections: async () => ({}),
|
||||
},
|
||||
"./B06_Section_UI_Page_Ford_Controls": {
|
||||
createBodyControls: () => ({ ford: bodyStub(), box: bodyStub() }),
|
||||
},
|
||||
"./B06_Section_UI_Page_Span_Control": {
|
||||
createSpanControl: () => ({
|
||||
control: {},
|
||||
spanKeyOf: (chainageM, wall) => `${chainageM.toFixed(2)}:${wall}`,
|
||||
}),
|
||||
},
|
||||
"./B06_Section_Section_Drafts": {
|
||||
withDraftStations: async () => undefined,
|
||||
withDraftShapes: async () => undefined,
|
||||
forgetDraftStations: () => undefined,
|
||||
supersedeDraftStations: () => undefined,
|
||||
},
|
||||
"./B06_Section_Structure_Layouts": {
|
||||
applyStructureAreaRows: () => undefined,
|
||||
structureAreaRows: () => [],
|
||||
computeStoredLayouts: () => null,
|
||||
},
|
||||
"../B05_Profile/B05_Profile_Api_PlusStations": {
|
||||
loadSavedPlusStations: async () => undefined,
|
||||
plusStationsOf: () => [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const P = "p445";
|
||||
const R = 7;
|
||||
const { stateKey } = load(path.join(ROOT, "A00_Common", "b_page_state.ts"));
|
||||
const store = load(B06("B06_Section_Section_Store"));
|
||||
const { buildCrossPatches } = load(B06("logic/B06_Section_UI_Page_Patches"));
|
||||
const { createStationControls } = load(B06("B06_Section_UI_Page_Station_Controls"));
|
||||
|
||||
/** 새 탭 = 빈 세션 · 서버 상세(정본) 받아 캐시에 · 조정창 하나. */
|
||||
async function freshTab(clearSession = true) {
|
||||
if (clearSession) globalThis.sessionStorage.clear();
|
||||
store.invalidateSectionDetail(P, R);
|
||||
const detail = await store.loadSectionDetail(P, R);
|
||||
const controls = createStationControls({
|
||||
sessionKey: (kind) => stateKey(kind, P, R),
|
||||
refreshCard: () => undefined,
|
||||
detail: () => detail,
|
||||
crossHalfWidth: () => undefined,
|
||||
sampledHalfWidth: () => 20,
|
||||
ensureSampledWidth: async () => true,
|
||||
projectId: () => P,
|
||||
});
|
||||
controls.load();
|
||||
return { detail, controls };
|
||||
}
|
||||
|
||||
/** 횡단 보기 [저장] 본문 — 세션 고침(`buildCrossPatches`) → 바탕 위에 얹음(`cachedCrossPatches`). */
|
||||
function savePatches(controls) {
|
||||
const overlay = buildCrossPatches({
|
||||
rockOffsets: new Map(),
|
||||
cutSlopeRatios: new Map(),
|
||||
stationWidths: controls.widths,
|
||||
inletStructures: controls.inletStructures,
|
||||
basinAdjustments: controls.basinAdjustments,
|
||||
revetAdjusts: controls.revetAdjustsByChainage(),
|
||||
extraCounts: controls.extraCountsByChainage(),
|
||||
extraSpans: controls.extraSpansByChainage(),
|
||||
fordAdjusts: new Map(),
|
||||
boxAdjusts: new Map(),
|
||||
linkFlags: controls.linkFlagsByChainage(),
|
||||
});
|
||||
return { overlay, patches: store.cachedCrossPatches(P, R, overlay) };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const at = job.at;
|
||||
const out = {};
|
||||
|
||||
// H3 — 유출만 고침 → 유입 · 집수 쪽 단 수 남음
|
||||
{
|
||||
const { controls } = await freshTab();
|
||||
controls.revetOffset.update(at, "outlet", { x: 3 });
|
||||
out.h3 = savePatches(controls);
|
||||
}
|
||||
// M2 — 새 탭 ↺(유입) · 추가 벽 − · 빈 전체 폭 · 집수정 내공 2.1(M1)
|
||||
{
|
||||
const { controls } = await freshTab();
|
||||
controls.revetOffset.reset(at, "inlet");
|
||||
controls.extraWalls.setCount(at, 0, "outlet");
|
||||
controls.applyGlobalWidth(undefined, [at]);
|
||||
controls.inletStructure.updateAdjust(at, { innerWidthM: 2.1, innerHeightM: 0.5 });
|
||||
out.m2 = savePatches(controls);
|
||||
out.m2.countShown = controls.extraWalls.countFor({ chainage_m: at, design: {} }, "outlet");
|
||||
// 저장 전 새로고침(세션은 남고 상세는 정본) — 되돌림이 살아 있어야 함
|
||||
const again = await freshTab(false);
|
||||
out.m2reload = savePatches(again.controls);
|
||||
out.m2reload.cachedInlet = again.detail.cross_sections[0].design.revet_adjust?.inlet ?? null;
|
||||
out.m2reload.cachedCounts = again.detail.cross_sections[0].design.extra_wall_counts ?? null;
|
||||
}
|
||||
// H2 — 정본 행이 없는 자리(옮긴 관)의 캐시 설계 = 지반 등 입력까지 실음
|
||||
{
|
||||
globalThis.sessionStorage.clear();
|
||||
store.invalidateSectionDetail(P, R);
|
||||
await store.loadSectionDetail(P, R); // 정본 = job.detail(at 한 측점)
|
||||
const moved = await store.loadSectionDetail(P, R);
|
||||
moved.cross_sections.push({ ...job.moved });
|
||||
out.h2 = store.cachedCrossPatches(P, R, []);
|
||||
moved.cross_sections.pop();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -245,7 +245,7 @@ def test_wiring_sources() -> None:
|
||||
assert "patchFor(at).structure_reset = true" in persist
|
||||
assert 'clearState("kindreset", ctx.projectId)' in persist
|
||||
store = _src("B06_Section", "B06_Section_Section_Store.ts")
|
||||
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId))" in store
|
||||
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store
|
||||
view = _src("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "setStationAdjustReload(() => stationControls.load())" in view
|
||||
|
||||
@@ -344,7 +344,7 @@ def test_merge_none_only_entry_for_missing_row_inserts_nothing() -> None:
|
||||
def test_apply_edits_resets_before_patches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[list, bool]] = []
|
||||
|
||||
async def fake_merge(connection, *, route_id, entries, replace, project_id=None):
|
||||
async def fake_merge(connection, *, route_id, entries, replace, project_id=None, missed=None):
|
||||
calls.append((list(entries), replace))
|
||||
return len(entries)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
def test_면적_보내도_정본에_안_적음(monkeypatch):
|
||||
written: list = []
|
||||
|
||||
async def merge(connection, *, route_id, entries, replace, project_id=None):
|
||||
async def merge(connection, *, route_id, entries, replace, project_id=None, missed=None):
|
||||
written.append((replace, entries))
|
||||
return 0
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_면적_보내도_정본에_안_적음(monkeypatch):
|
||||
def test_면적만_보낸_측점은_patch_없음(monkeypatch):
|
||||
written: list = []
|
||||
|
||||
async def merge(connection, *, route_id, entries, replace, project_id=None):
|
||||
async def merge(connection, *, route_id, entries, replace, project_id=None, missed=None):
|
||||
written.append(entries)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
요구 255-1 · 257-1 · 261 · 분석 424 1장 · 7장.
|
||||
|
||||
못박는 것
|
||||
- 0.5 m 넘게 옮긴 관 측점 = 기본 설계(새 상단측 깎기 · 리핑암 · 표준 측구)
|
||||
+ 사용자가 만진 칸 — 서버가 그 행을 지우고 기본 설계를 세우는 것과 같음 ·
|
||||
0.5 m 안이면 앞 설계 그대로.
|
||||
- 0.5 m 넘게 옮긴 관 측점 = 기본 설계(새 상단측 깎기) + 사용자가 만진 칸 + 지반 등
|
||||
입력(지반 · 측구 종류 · 2단 경사 · 암선 — 445 H2 · 서버 `CARRY_INPUT_KEYS`) — 서버가
|
||||
그 행을 지우고 patch 의 그 입력으로 기본 설계를 세우는 것과 같음 · 0.5 m 안이면 앞 설계 그대로.
|
||||
- 저장 노선 다시 셈(`refreshCrossDesigns`)이 미교차 측점을 서버 `extend_unclosed`
|
||||
와 같이 넓힘 → 브라우저 관 길이 = 서버 관 길이(같은 기하 함수 · 표본 · 설계).
|
||||
- 관 초안 얹기 — 따라가기만 한 관(땅 위 자리 = 정본 x · y) = 서버 누가거리 ·
|
||||
@@ -56,25 +56,30 @@ CULVERT = {
|
||||
}
|
||||
|
||||
|
||||
# 옛 자리 사용자 입력 — 옮긴 측점이 들고 감(445 H2 · 서버 `_carried_inputs` → `compute_default_designs`)
|
||||
CARRIED = {"ground_type": "soil", "two_stage_slope": True, "rock_boundary_offset_m": 9.0}
|
||||
|
||||
|
||||
def _pipe_section(line, sampler, chainage: float) -> dict:
|
||||
data = _sections(line, sampler, [{"id": "p-a", "chainage_m": chainage, "source": "user"}])
|
||||
return next(s for s in data["cross_sections"] if abs(s["chainage_m"] - chainage) < 1e-6)
|
||||
|
||||
|
||||
def _server(section: dict, elevation: float, sampler):
|
||||
"""서버 길 — 옮긴 관 측점 = 기본 설계(상단측 깎기 · `compute_default_designs`)
|
||||
"""서버 길 — 옮긴 관 측점 = 기본 설계(상단측 깎기 · 들고 온 입력 · `compute_default_designs`)
|
||||
→ [저장] 보정 맨 앞 미교차 넓힘(`extend_unclosed_sections` — 저장 설계의
|
||||
단면 유형 · 측구 칸을 입력으로 다시 셈)."""
|
||||
base = {
|
||||
"ground_type": "ripping_rock",
|
||||
"ground_type": CARRIED["ground_type"],
|
||||
"ditch_type": "standard",
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
"rock_boundary_offset_m": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
"rock_boundary_offset_m": CARRIED["rock_boundary_offset_m"],
|
||||
}
|
||||
stored = compute_cross_design(
|
||||
section["samples"],
|
||||
elevation,
|
||||
section_mode=f"{section.get('uphill_side') or 'left'}_cut",
|
||||
two_stage_slope=CARRIED["two_stage_slope"],
|
||||
**base,
|
||||
)
|
||||
|
||||
@@ -140,11 +145,9 @@ def r(tmp_path_factory) -> dict:
|
||||
other = "right" if uphill == "left" else "left"
|
||||
old["design"] = {
|
||||
"design_elevation_m": elevation,
|
||||
"ground_type": "soil",
|
||||
**CARRIED,
|
||||
"section_mode": f"{other}_cut",
|
||||
"ditch_side": other,
|
||||
"two_stage_slope": True,
|
||||
"rock_boundary_offset_m": 9.0,
|
||||
"inlet_structure": "revet",
|
||||
}
|
||||
job = {
|
||||
@@ -179,10 +182,11 @@ def r(tmp_path_factory) -> dict:
|
||||
def test_옮긴_관_측점_기본_설계와_만진_칸(r):
|
||||
s = r["swapped"]
|
||||
assert s["uphill"] == r["job"]["uphill"] # 상단측 = 새 지반
|
||||
assert s["section_mode"] == f"{r['job']['uphill']}_cut"
|
||||
assert s["ground_type"] == "ripping_rock"
|
||||
assert s["ditch_side"] is None and s["two_stage_slope"] is None
|
||||
assert s["rock_boundary_offset_m"] is None
|
||||
assert s["section_mode"] == f"{r['job']['uphill']}_cut" # 단면 유형 = 새 지형
|
||||
assert s["ditch_side"] is None # 측구 방향 = 새 지형(다시 셈)
|
||||
# 지반 등 입력은 들고 감(445 H2 — 예전엔 리핑암 · 기본 암선으로 돌아가 DB 와 갈림)
|
||||
assert s["ground_type"] == "soil" and s["two_stage_slope"] is True
|
||||
assert s["rock_boundary_offset_m"] == 9.0
|
||||
assert s["inlet_structure"] == "revet" # 사용자가 만진 칸은 따라감
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ def _ground() -> list[dict]:
|
||||
|
||||
|
||||
def test_노선_저장_뒤_고침이_없어도_횡단_저장_한_번(out):
|
||||
assert out["noPatchSent"] == [["p1", 9, None, None]] # 본문 없이 = 서버가 다시 셈만
|
||||
# 본문 없이 = 서버가 다시 셈만 · 풍선 자리는 늘 실음(445 H5 — 횡단 보기 [저장]과 같은 본문)
|
||||
assert out["noPatchSent"] == [["p1", 9, None, None, {"balloon_offsets": {}}]]
|
||||
assert out["noPatchReturn"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""PLAN 445 — [저장] 횡단 patch 바탕 · 되돌림 · 옮긴 측점 · 집수정 범위 · 버린 수.
|
||||
|
||||
요구 268-3 · 분석 `docs/raw/조사/2026-10-18_442_요구268_저장누락.md`
|
||||
(H2 · H3 · M2 · H5 · M1 · M5 · M7).
|
||||
|
||||
못박는 것(조정창으로 고침 → 횡단 보기 [저장] 본문 → 서버 병합 → DB 설계)
|
||||
- H3 — 새 탭에서 유출 벽만 고쳐도 유입 벽 · 집수 쪽 단 수가 남음(바탕 = 캐시 설계 통째).
|
||||
- M2 — 새 탭 ↺ · 추가 벽 0 · 빈 전체 폭이 저장분을 지움 · 저장 전 새로고침에도 되돌림이 남음.
|
||||
- M1 — 집수정 내공 2.1 → 서버 범위 2.0 으로 잘림(422 없음).
|
||||
- H2 — 정본 행이 없는 자리(옮긴 관)는 지반 등 입력까지 실음 · 서버 기본 설계가 그 입력으로 섬.
|
||||
- M5 — 행 없는 측점 patch 를 버린 수 · 응답 칸.
|
||||
- H5 · M7 — 노선 저장 뒤 본문 · kindreset 옮김(소스 배선).
|
||||
짝: `helper_445_save_patch.cjs`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section import B06_Section_Router_Confirm as confirm # noqa: E402
|
||||
from B06_Section.B06_Section_Repository_Bulk import ( # noqa: E402
|
||||
merge_cross_section_design_batches,
|
||||
)
|
||||
from B06_Section.B06_Section_Schema import ( # noqa: E402
|
||||
CrossSectionPatch,
|
||||
SectionConfirmRequest,
|
||||
SectionConfirmResponse,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
AT = 100.0
|
||||
SAVED = {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"structure_note": "메모",
|
||||
"display_half_width_m": 8.0,
|
||||
"revet_adjust": {
|
||||
"inlet": {"x": 1.0, "d": None, "h": None, "m": None},
|
||||
"outlet": {"x": 2.0, "d": None, "h": None, "m": None},
|
||||
},
|
||||
"extra_wall_counts": {"outlet": 1, "basin": 2},
|
||||
"basin_adjust": {"innerWidthM": 1.5, "innerHeightM": 1.2, "lateralM": 0, "slopeM": 0},
|
||||
}
|
||||
MOVED = {
|
||||
"chainage_m": 130.0,
|
||||
"kind": "irregular",
|
||||
"design": {
|
||||
"ground_type": "soil",
|
||||
"ditch_type": "l_type",
|
||||
"two_stage_slope": True,
|
||||
"rock_boundary_offset_m": -1.5,
|
||||
"structure_note": "옮긴 관 메모",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory) -> dict:
|
||||
work = tmp_path_factory.mktemp("b445")
|
||||
job = {
|
||||
"at": AT,
|
||||
"detail": {
|
||||
"longitudinal": {"length_m": 500, "stations": []},
|
||||
"cross_sections": [{"chainage_m": AT, "kind": "irregular", "design": SAVED}],
|
||||
},
|
||||
"moved": MOVED,
|
||||
}
|
||||
path = work / "job.json"
|
||||
path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_445_save_patch.cjs"), str(path)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
self.connection.log.append((" ".join(sql.split()), list(params or [])))
|
||||
|
||||
async def fetchall(self):
|
||||
return self.connection.rows
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.log: list = []
|
||||
|
||||
def cursor(self, *args):
|
||||
return _Cursor(self)
|
||||
|
||||
|
||||
def _server_save(patches: list[dict], rows=None) -> tuple[dict, list[float]]:
|
||||
"""화면 patch → 스키마 → 서버 병합(실제 `merge_cross_section_design_batches`) → 저장된 설계."""
|
||||
request = SectionConfirmRequest(cross_patches=patches)
|
||||
rows = rows or [(1, AT, json.dumps({"design": SAVED}, ensure_ascii=False))]
|
||||
connection = _Connection(rows)
|
||||
missed = asyncio.run(confirm._apply_section_edits(connection, 7, request, []))
|
||||
updates = [params for sql, params in connection.log if sql.startswith("UPDATE")]
|
||||
if not updates:
|
||||
return SAVED, missed
|
||||
return json.loads(updates[-1][1])["design"], missed
|
||||
|
||||
|
||||
def test_H3_한_벽만_고쳐도_다른_벽_단_수_남음(r):
|
||||
design, _ = _server_save(r["h3"]["patches"])
|
||||
assert design["revet_adjust"]["outlet"]["x"] == 3.0
|
||||
assert design["revet_adjust"]["inlet"]["x"] == 1.0 # 예전: 유입 0(세션 몫만 보내 통째 교체)
|
||||
assert design["extra_wall_counts"] == {"outlet": 1, "basin": 2}
|
||||
assert design["structure_note"] == "메모" and design["display_half_width_m"] == 8.0
|
||||
# 세션 몫만으로는 유입이 빠짐 — 바탕이 메운 것
|
||||
assert set(r["h3"]["overlay"][0]["revet_adjust"]) == {"outlet"}
|
||||
old, _ = _server_save(r["h3"]["overlay"]) # 옛 길(세션 몫만) = 유입이 사라짐
|
||||
assert "inlet" not in old["revet_adjust"]
|
||||
|
||||
|
||||
def test_M2_되돌림이_저장분을_지움(r):
|
||||
design, _ = _server_save(r["m2"]["patches"])
|
||||
assert set(design["revet_adjust"]) == {"outlet"} # ↺ 유입 = 지움
|
||||
assert design["extra_wall_counts"] == {"outlet": 0, "basin": 2} # 새 탭 − = 0 · 집수 쪽 그대로
|
||||
assert design["display_half_width_m"] == 0 # 빈 전체 폭 = 개별 폭 없음(읽는 쪽 0 이하 = 없음)
|
||||
assert r["m2"]["countShown"] == 0
|
||||
|
||||
|
||||
def test_M2_저장_전_새로고침에도_되돌림이_남음(r):
|
||||
reload = r["m2reload"]
|
||||
assert reload["cachedInlet"] is None # 세션 null 이 정본 벽을 다시 뺌
|
||||
assert reload["cachedCounts"] == {"outlet": 0, "basin": 2}
|
||||
design, _ = _server_save(reload["patches"])
|
||||
assert set(design["revet_adjust"]) == {"outlet"}
|
||||
assert design["extra_wall_counts"] == {"outlet": 0, "basin": 2}
|
||||
assert design["display_half_width_m"] == 0
|
||||
|
||||
|
||||
def test_M1_집수정_내공_서버_범위로_잘림(r):
|
||||
basin = next(p for p in r["m2"]["patches"] if "basin_adjust" in p)["basin_adjust"]
|
||||
assert basin["innerWidthM"] == 2.0 and basin["innerHeightM"] == 1.0
|
||||
design, _ = _server_save(r["m2"]["patches"])
|
||||
assert design["basin_adjust"]["innerWidthM"] == 2.0
|
||||
with pytest.raises(ValidationError): # 서버 범위 그대로(화면이 맞춤)
|
||||
CrossSectionPatch(chainage_m=AT, basin_adjust={**SAVED["basin_adjust"], "innerWidthM": 2.1})
|
||||
|
||||
|
||||
def test_H2_옮긴_측점은_지반_등_입력까지(r):
|
||||
moved = next(p for p in r["h2"] if abs(p["chainage_m"] - MOVED["chainage_m"]) < 0.01)
|
||||
for key in ("ground_type", "ditch_type", "two_stage_slope", "rock_boundary_offset_m"):
|
||||
assert moved[key] == MOVED["design"][key], key
|
||||
assert moved["structure_note"] == "옮긴 관 메모"
|
||||
kept = next(p for p in r["h2"] if abs(p["chainage_m"] - AT) < 0.01)
|
||||
assert (
|
||||
"ground_type" not in kept
|
||||
) # 정본 행이 있는 측점은 사용자 칸만(결과 칸 · 입력은 정본 그대로)
|
||||
# 서버 — 행 없는 자리의 기본 설계를 그 입력으로 세움(`compute_default_designs(carried=…)`)
|
||||
request = SectionConfirmRequest(cross_patches=r["h2"])
|
||||
carried = confirm._carried_inputs(request, [130.004])
|
||||
assert carried == {
|
||||
130.004: {
|
||||
"ground_type": "soil",
|
||||
"ditch_type": "l_type",
|
||||
"two_stage_slope": True,
|
||||
"rock_boundary_offset_m": -1.5,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_M5_행_없는_측점_patch_버린_수():
|
||||
missed: list[float] = []
|
||||
connection = _Connection([(1, AT, json.dumps({"design": SAVED}))])
|
||||
asyncio.run(
|
||||
merge_cross_section_design_batches(
|
||||
connection,
|
||||
route_id=7,
|
||||
batches=[
|
||||
([(AT, {"structure_note": "x"}), (250.0, {"structure_note": "y"})], False, None)
|
||||
],
|
||||
missed=missed,
|
||||
)
|
||||
)
|
||||
assert missed == [250.0]
|
||||
assert confirm._default_failed([10.0, 20.0], [(10.004, {})]) == [20.0]
|
||||
fields = SectionConfirmResponse.model_fields
|
||||
assert {"dropped_patch_chainages", "default_failed_chainages", "stations_synced"} <= set(fields)
|
||||
api = (ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8")
|
||||
assert "result.dropped_patch_chainages" in api and "stations_synced === false" in api
|
||||
|
||||
|
||||
def test_M5_새_행은_버린_수에_안_셈():
|
||||
missed: list[float] = []
|
||||
connection = _Connection([])
|
||||
asyncio.run(
|
||||
merge_cross_section_design_batches(
|
||||
connection,
|
||||
route_id=7,
|
||||
batches=[([(300.0, {"ground_type": "soil"})], True, uuid4())],
|
||||
missed=missed,
|
||||
)
|
||||
)
|
||||
assert missed == []
|
||||
|
||||
|
||||
def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
|
||||
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
|
||||
body = store[store.index("export async function saveCachedCrossPatches") :]
|
||||
for needle in (
|
||||
"sessionOverlay(projectId, routeId)",
|
||||
"readStandardCrossSession(projectId)",
|
||||
"balloon_offsets: balloonOffsetsPayload()",
|
||||
):
|
||||
assert needle in body, needle
|
||||
overlay = store[store.index("function sessionOverlay") :]
|
||||
for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("):
|
||||
assert needle in overlay, needle
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches)" in persist
|
||||
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8")
|
||||
carry = keys[keys.index("export function carryChainageStates") :]
|
||||
assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry
|
||||
idle = keys[keys.index("function moveIdleStates") :]
|
||||
assert 'writeState("kindreset", move(resets), projectId)' in idle
|
||||
|
||||
|
||||
def test_H2_B06_관_폼_초안이_관_id_를_실음():
|
||||
"""B06 관 폼이 초안에 관 id 를 빠뜨리면 측점 열쇠가 `pipe:p…` → `pipe:pipe-356.00` 으로 바뀌어
|
||||
옮긴 측점이 앞 설계(지반 · 메모 · 조절)를 잃음 — 화면 실측(관 351 → 356)."""
|
||||
panel = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
load = panel[panel.index("pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points)") :]
|
||||
assert "id: pipe.id" in load[:200]
|
||||
persist = panel[panel.index("function persistPipes") :]
|
||||
assert "...(pipe.id ? { id: pipe.id } : {})" in persist[:400]
|
||||
@@ -72,7 +72,7 @@ def _run_save(monkeypatch: pytest.MonkeyPatch, missing: list[float]) -> list[flo
|
||||
# 행 자체가 없는 측점은 이 시험의 관심사가 아니다 — 빈 목록으로 고정한다.
|
||||
monkeypatch.setattr(confirm, "_rowless_station_chainages", lambda *_a, **_k: [])
|
||||
|
||||
def _spy(_root, _path, chainages, _standard):
|
||||
def _spy(_root, _path, chainages, _standard, _carried=None):
|
||||
seen.extend(chainages)
|
||||
return [(value, {"ground_type": "soil"}) for value in chainages]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user