From b68d6ff12e1bdc15fe284104ca6209cb2fd9af87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:10:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(451=20B06=20=EC=B4=88=EC=95=88=20=EB=B2=84?= =?UTF-8?q?=EB=A6=BC):=20=EB=B2=84=EB=A6=BC=20=EC=B0=A8=EB=A1=80=EA=B0=80?= =?UTF-8?q?=20=EB=92=A4=20cross=20=EC=9E=85=EB=A0=A5=EC=97=90=20=EB=B0=80?= =?UTF-8?q?=EB=A0=A4=EB=8F=84=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=EB=81=9D=EC=97=90=EC=84=9C=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=BC=20=E2=80=94=20B05=20=EC=99=80=20=EA=B0=99=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 버림 차례가 뒤 차례에 밀림(dropped) · 노선 초안 그대로 없음 = 마지막 차례 끝에서 finishDrop 다시(새 초안이 밀면 안 함) - 밀어낸 차례도 조용히(가게에 측점 안 냄 · 종단도는 되돌림 뒤 저장 기준) - 새 test_451_drop_cross(진짜 묶음 · 가짜 지형) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_page.ts | 10 + resources/tester/helper_451_drop_cross.cjs | 303 +++++++++++++++++++++ resources/tester/test_451_drop_cross.py | 63 +++++ 3 files changed, 376 insertions(+) create mode 100644 resources/tester/helper_451_drop_cross.cjs create mode 100644 resources/tester/test_451_drop_cross.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 073c0b1cd..7adc76394 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -223,6 +223,8 @@ function createPagePipeline( } | null = null; /** 초안을 버린 차례가 끝나 되돌림까지 마치는 약속(종단도는 그 뒤 저장 기준으로 — 349-0l). */ let dropping: Promise | null = null; + /** 초안을 버린 차례가 되돌림까지 못 감(뒤 차례에 밀림) — 뒤 차례도 조용히 · 그 끝에서 되돌림(B06 cross · 451). */ + let dropPending = false; /** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */ let beforeDraft: Facilities | null = null; /** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */ @@ -402,6 +404,11 @@ function createPagePipeline( * 기록에) · 그 상세가 저장 정본(다시 하기도 같은 기준). 차례 끝에서 판정 — ground 를 건너뛰어도 · 사진첩 * 되돌리기가 가게 초안을 먼저 비워도(349-0m). */ const finishDrop = async (record: PipelineRunRecord): Promise => { + // 뒤 차례(B06 cross 등)에 밀림 · 초안은 그대로 버려짐 — 마지막 차례 끝에서 되돌림(B05 와 같게) + const pushed = record.outcome === "dropped" && record.generation !== pipe.generation; + if (pushed && dropPending && !pipe.input("route")) + return (lastRun as Promise).then(finishDrop); + dropPending = false; const routeId = canon?.routeId; const stations = pipe.output("ground")?.stations ?? []; let why: string = record.outcome; // 새 조작에 밀림 · 단계 실패 — 초안 전 상세는 그대로 둠 @@ -534,6 +541,7 @@ function createPagePipeline( // 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄 held = inputs.route ? (held ?? holdNow()) : null; dropping = null; + dropPending = false; Object.assign(given, inputs); const run = (lastRun = pipe.start(inputs)); trackPageBusy(projectId, run); // 「계산 중」 점(386-2) @@ -549,6 +557,7 @@ function createPagePipeline( given[SOURCE_INPUT[source]] = value as never; const run = pipe.update(source, value, part); if (run) trackPageBusy(projectId, (lastRun = run)); + if (run && dropPending && !pipe.input("route")) quietGeneration = pipe.generation; // 되돌림이 맡음 return run; }; let reconciling: Promise | null = null; @@ -648,6 +657,7 @@ function createPagePipeline( const run = feed("route", draft, routeRange(before, after)); if (run && !draft && wasDraft) { quietGeneration = pipe.generation; + dropPending = true; dropping = run.then(finishDrop); } }, diff --git a/resources/tester/helper_451_drop_cross.cjs b/resources/tester/helper_451_drop_cross.cjs new file mode 100644 index 000000000..b39daccdb --- /dev/null +++ b/resources/tester/helper_451_drop_cross.cjs @@ -0,0 +1,303 @@ +/* 451 헬퍼 — B06 에서 노선 초안 버림 바로 뒤 cross 입력(횡단 보기)이 와도 되돌림까지(B05 와 같게). 진짜 TS + * (`design_pipeline_page` · 단계 파일 전부)를 Node 로 · 서버 창구만 가짜(틀 = `helper_365_page_pipeline.cjs`). + * 입력 argv[2] JSON · argv[3] 격자 이진 · 결과 = 표준출력 JSON. 짝 = `test_451_drop_cross.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +const haul = { calls: 0, aborted: 0, answered: 0 }; +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + haul.calls += 1; + const timer = setTimeout(() => { + haul.answered += 1; + resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }); + }, job.haulDelayMs); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + haul.aborted += 1; + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + 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 localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: [], + min_radius_m: job.minRadius, + station_snap: false, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { + effectiveStandardCross: () => job.standard, + readRockBoundaryDefault: () => -0.5, +}; + +const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"); +const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +async function settle(count) { + for (let i = 0; i < 400; i += 1) { + const last = runs()[runs().length - 1]; + if (runs().length >= count && last && last.outcome !== "running") return last; + await tick(5); + } + throw new Error("묶음이 끝나지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); + +/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */ +async function savedBaseOf() { + const seed = pipeMod.createDesignPipeline({ + stages: { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + stations: stationsMod.createStationsStage({ + intervalM: job.interval, + types: () => job.types, + }), + ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }), + }, + }); + await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] }); + const ground = seed.output("ground"); + seed.dispose(); + const samples = ground.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground.longitudinal.z[i], + })); + const profile = gradeGround.groundProfile(samples); + return { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; +} + +const store = load("A00_Common", "design", "design_store.ts"); +// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜 +STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; +STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; +STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖 +// B06 카드 그림(393-2) — 화면 밖 · 판 값 고르기는 진짜 +STUBS["B05_Profile_UI_RouteEdit_Cross_Card"] = { + routeCrossSource: () => null, + routeCrossCard: () => null, +}; +const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); +const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); + +/** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */ +function shownRows(P) { + const shown = pageMod.pipelineDetail(P); + return ( + shown && + shown.cross_sections.map((s) => [ + r3(s.chainage_m), + r3(s.design?.design_elevation_m), + r3(s.design?.cut_area_m2), + r3(s.design?.fill_area_m2), + ]) + ); +} + +async function run() { + const P = "proj-451"; + window.localStorage.setItem("frd_current_project_id", P); + const savedBase = await savedBaseOf(); + const shop = store.useDesignStore(P, null); + const restores = []; + const adopts = []; + const bridge = pageMod.routeDraftBridge( + P, + { + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: (_base, _edits, draft) => adopts.push(draft ? "draft" : "saved"), + redraw: () => undefined, + }, + { + stationIntervalM: job.interval, + holdDraft: () => async (stations) => { + restores.push({ at: adopts.length, stations: stations.length }); + return "saved"; + }, + }, + ); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + await settle(runs().length + 1); + const savedStations = (shop.get("stations") ?? []).map((s) => s.chainageM); + bridge.edited(draftOf(shift(1, 8, -6))); + await settle(runs().length + 1); + await tick(20); + const draftStations = (shop.get("stations") ?? []).map((s) => s.chainageM); + + // 버림 바로 뒤 cross 입력 — 횡단 보기가 세션 고침을 묶음에 넘김(B06) + const mark = runs().length; + adopts.length = 0; + bridge.edited(null); + await tick(2); // 버림 차례가 돌기 시작한 뒤 + const wider = { ...job.standard, road_width_m: (job.standard.road_width_m ?? 3) + 0.5 }; + STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = () => wider; // 표준 고침 = 새 cross 입력 + pageMod.pipelineCross(P); + for (let i = 0; i < 400 && !restores.length; i += 1) await tick(5); + await pageMod.pagePipeline(P).settled(); + await tick(30); + const after = runs().slice(mark); + const out = { + moved: JSON.stringify(savedStations) !== JSON.stringify(draftStations), + runs: after.map((r) => [r.sources.join("+"), r.outcome, r.restoreWhy ?? ""]), + restores: [...restores], + adopts: [...adopts], + stationsPublished: + JSON.stringify((shop.get("stations") ?? []).map((s) => s.chainageM)) !== + JSON.stringify(draftStations), + routeDraft: shop.get("routeDraft"), + }; + // 버림이 안 밀린 길(B05) — 그대로 한 번 + bridge.edited(draftOf(shift(1, 8, -6))); + await settle(runs().length + 1); + const mark2 = runs().length; + restores.length = 0; + bridge.edited(null); + for (let i = 0; i < 400 && !restores.length; i += 1) await tick(5); + await pageMod.pagePipeline(P).settled(); + out.plain = { + runs: runs() + .slice(mark2) + .map((r) => [r.sources.join("+"), r.outcome, r.restoreWhy ?? ""]), + restores: restores.length, + }; + // 버림 뒤 새 초안이 밀어냄 — 되돌림 없음(초안이 이김) + const mark3 = runs().length; + restores.length = 0; + bridge.edited(null); + bridge.edited(draftOf(shift(1, 8, -6))); + await settle(runs().length + 1); + await pageMod.pagePipeline(P).settled(); + await tick(30); + out.redraft = { + runs: runs() + .slice(mark3) + .map((r) => [r.sources.join("+"), r.outcome, r.restoreWhy ?? ""]), + restores: restores.length, + draft: shop.get("routeDraft") !== null, + }; + pageMod.disposePagePipeline(P); + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_451_drop_cross.py b/resources/tester/test_451_drop_cross.py new file mode 100644 index 000000000..885b3753d --- /dev/null +++ b/resources/tester/test_451_drop_cross.py @@ -0,0 +1,63 @@ +"""451 B06 에서 노선 초안 버림 바로 뒤 cross 입력이 와도 되돌림까지(B05 와 같게). + +① 버림 차례가 뒤 차례(cross)에 밀리면 마지막 차례 끝에서 되돌림 한 번 · 밀어낸 차례도 가게에 + 측점 안 냄 · 종단도는 되돌림 뒤 저장 기준 +② 안 밀린 버림(B05) = 그 차례에서 되돌림 그대로 +③ 버림 뒤 새 초안이 밀어냄 = 되돌림 없음(초안이 이김) +짝: `helper_451_drop_cross.cjs`(진짜 TS · 서버 창구만 가짜 · 가짜 지형 = 346-2 틀). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_451_drop_cross.py -q +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +import test_365_page_pipeline as page # noqa: E402 + +pytestmark = page.pytestmark + + +@pytest.fixture(scope="module") +def r() -> dict: + with tempfile.TemporaryDirectory() as folder: + path, grid = page._job(Path(folder)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_451_drop_cross.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_밀린_버림도_되돌림(r) -> None: + assert r["moved"] is True # 초안이 측점을 바꿈(시험이 갈아끼움 길을 거침) + assert r["runs"][0][1] == "dropped" # 버림 차례가 cross 에 밀림 + assert len(r["restores"]) == 1 + assert r["runs"][-1][1:] == ["done", "saved"] + assert r["stationsPublished"] is False # 밀어낸 차례도 조용히(되돌림이 맡음) + assert r["adopts"] == ["saved"] and r["restores"][0]["at"] == 0 # 종단도 = 되돌림 뒤 + assert r["routeDraft"] is None + + +def test_안_밀린_버림_그대로(r) -> None: + assert r["plain"]["runs"] == [["route", "done", "saved"]] + assert r["plain"]["restores"] == 1 + + +def test_새_초안이_밀면_되돌림_없음(r) -> None: + assert r["redraft"]["restores"] == 0 + assert r["redraft"]["draft"] is True