diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index f49124127..ef5f75fbe 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -406,7 +406,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { function draw(): void { renderProfile({ // 그린 뒤 생긴 낡음도 다시 셈(317-1) · 노선 초안 동안은 묶음 cross 를 기다림 - onStaleDesigns: (key) => (store === preview ? key !== "" : crossPreview.scheduleIfStale(key)), + onStaleDesigns: (key) => crossPreview.scheduleIfStale(key, store === preview), body, callbacks, massHaul, diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 5457feaf6..e45450ddb 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -7,12 +7,22 @@ * 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). * * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. + * 노선 초안 동안은 서버에 안 물음 — 계산 묶음 cross 가 그 차례를 끝낼 때까지만 「다시 계산 중」 · 그 뒤 남은 + * 낡음은 곡선을 그리고 그 측점만 표시(342-1 · 349-0f). * ========================================================================== */ import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import { pagePipeline } from "../A00_Common/design/design_pipeline_page"; +import { useDesignStore } from "../A00_Common/design/design_store"; +import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline"; + +/** cross 를 지났다고 볼 단계(cross 가 같은 입력이라 건너뛰어도 뒤 단계가 알림). */ +const AFTER_CROSS = new Set(["cross", "footprint", "masshaul"]); +/** 묶음 차례가 cross 를 못 낸 채 끝나도(단계 실패) 「다시 계산 중」을 푸는 때(ms). */ +const DRAFT_WAIT_MS = 3000; export interface CrossPreviewContext { projectId: string; @@ -34,8 +44,9 @@ export interface CrossPreview { * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). * 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고 * 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16). + * `draft` = 노선 초안 동안 — 서버에 안 묻고 묶음 cross 차례만 기다림(349-0f). */ - scheduleIfStale: (key: string) => boolean; + scheduleIfStale: (key: string, draft?: boolean) => boolean; /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ dispose: () => void; } @@ -51,16 +62,43 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { routeId === ctx.routeId() ? ctx.alignment() : null, ); + /** 노선 초안 동안 기다리는 묶음 차례 — 그 차례 cross(또는 뒤 단계)가 오면 풂. */ + let awaiting: number | null = null; + let waitTimer = 0; + let watched: DesignPipeline | null = null; + const onStage = (event: PipelineEvent): void => { + if (event.kind === "stage" && event.generation === awaiting && AFTER_CROSS.has(event.stage)) + awaiting = null; + }; + const stopGeneration = useDesignStore(ctx.projectId, null).subscribe("generation", (gen) => { + const pipe = pagePipeline(ctx.projectId); + if (!pipe || gen === null) return; + if (watched !== pipe) (watched = pipe).subscribe(onStage); + awaiting = gen; + window.clearTimeout(waitTimer); + waitTimer = window.setTimeout(() => { + if (awaiting !== gen) return; + awaiting = null; + ctx.onApplied([]); + }, DRAFT_WAIT_MS); + }); + const warnLeft = (key: string): void => { + if (!key || key === warned) return; + warned = key; + console.warn( + `[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`, + staleChainages(key), + ); + }; + const preview: CrossPreview = { - scheduleIfStale(key) { - if (key && key !== lastStale) preview.schedule(); - else if (key && !busy && key !== warned) { - warned = key; - console.warn( - `[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`, - staleChainages(key), - ); + scheduleIfStale(key, draft = false) { + if (draft) { + if (awaiting === null) warnLeft(key); + return key !== "" && awaiting !== null; } + if (key && key !== lastStale) preview.schedule(); + else if (!busy) warnLeft(key); lastStale = key; return key !== "" && busy; }, @@ -101,6 +139,8 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { }, dispose() { window.clearTimeout(timer); + window.clearTimeout(waitTimer); + stopGeneration(); release(); }, }; diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index e350e2d47..efb4b4839 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -5,7 +5,8 @@ * * · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진). * · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로. - * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있으면 셈 없이 재사용. + * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의 + * 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f). * · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음. * input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김). * ========================================================================== */ @@ -59,6 +60,13 @@ const inRange = (station: PipelineStation, ctx: StageContext<"cross">): boolean return station.chainageM >= range.fromM - 1e-6 && station.chainageM <= range.toM + 1e-6; }; +/** 앞 결과가 같은 자리 · 같은 계획고(엔진이 0.1 mm 로 반올림)인가 — 다르면(노면 하강 등) 지문 기억이 맡음. */ +const sameSpot = (kept: CrossStationResult, station: PipelineStation, elevation: number): boolean => + Math.abs(kept.chainageM - station.chainageM) < 1e-6 && + Math.abs( + Number((kept.detail as { design_elevation_m?: number } | null)?.design_elevation_m) - elevation, + ) <= 1e-4; + /** * `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수. */ @@ -76,13 +84,13 @@ export function createCrossStage() { last = { computed: 0, reused: 0 }; for (const station of ground.stations) { const kept = ctx.prev?.byKey[station.key]; - if (kept && !inRange(station, ctx)) { + const elevation = planElevationAt(profile, station.chainageM); + if (kept && !inRange(station, ctx) && sameSpot(kept, station, elevation)) { byKey[station.key] = { ...kept, chainageM: station.chainageM }; last.reused += 1; continue; } const samples = groundSamples(station); - const elevation = planElevationAt(profile, station.chainageM); const options = optionsFor(station, input.byKey[station.key], input.standard); const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`; const hit = memo.get(station.key); diff --git a/resources/tester/helper_349_0f_draft_stale.cjs b/resources/tester/helper_349_0f_draft_stale.cjs new file mode 100644 index 000000000..ac6197ebe --- /dev/null +++ b/resources/tester/helper_349_0f_draft_stale.cjs @@ -0,0 +1,285 @@ +/* PLAN 349-0f 헬퍼 — 노선 초안 두 번 끌기(길이 바뀜 + 가운데 꺾임점) 뒤 새 노선 횡단 낡음 · 「다시 계산 중」. + * 진짜 TS(`design_pipeline_page` · 단계 파일 · `B05_Profile_UI_Profile_Preview`)를 트랜스파일해 Node 로 돌림. + * 서버 창구만 가짜(틀 = `helper_346_2_pipeline_cross_page.cjs` 그대로). 입력 argv[2] JSON · argv[3] 격자 이진. + * 결과 = 표준출력 JSON. 짝 = `test_349_0f_draft_stale.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 }; +STUBS["B06_Section_Cross_Refresh"] = { + holdScreenAlignment: () => () => undefined, + refreshCrossDesigns: async () => [], +}; +STUBS["B06_Section_UI_Section_Common"] = { + staleChainages: (key) => (key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []), +}; + +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 storeMod = load("A00_Common", "design", "design_store.ts"); +const previewMod = load("B05_Profile", "B05_Profile_UI_Profile_Preview.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, + })), + }; +} + +/** 새 노선 상세의 낡은 측점 — 설계 계획고 ≠ 지금 계획선(`staleKey` 와 같은 잣대 1 mm). */ +function staleOf(P, pipe) { + const shown = pageMod.pipelineDetail(P); + const profile = pipe.output("profile"); + if (!shown || !profile) return null; + return shown.cross_sections + .filter( + (s) => + s.design && + Math.abs(s.design.design_elevation_m - alignment.planElevationAt(profile, s.chainage_m)) > + 1e-3, + ) + .map((s) => s.chainage_m); +} + +async function run() { + const P = "proj-3490f"; + window.localStorage.setItem("frd_current_project_id", P); + const savedBase = await savedBaseOf(); + const seedRuns = runs().length; + const link = { + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: () => undefined, + redraw: () => undefined, + }; + const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: job.interval }); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + const pipe = pageMod.pagePipeline(P); + let applied = 0; + const preview = previewMod.createCrossPreview({ + projectId: P, + detail: () => null, + routeId: () => 7, + edits: () => alignment.emptyEdits(), + alignment: () => null, + debounceMs: () => 0, + onApplied: () => (applied += 1), + }); + await settle(seedRuns + 1); + + // ① 2번째 꺾임점을 서쪽으로 20 m — 노선 길이 바뀜(job.drags[0]) + const [a, b] = job.drags; + const first = job.nodes.map((node, i) => + i === a.index ? [node[0] + a.dx, node[1] + a.dy] : node, + ); + bridge.edited(draftOf(first)); + const pendingDuring = preview.scheduleIfStale("1:2", true); + const runOne = await settle(runs().length + 1); + await tick(job.haulDelayMs + 20); + const one = { + outcome: runOne.outcome, + length: pipe.output("route").lengthM, + stale: staleOf(P, pipe), + pendingAfter: preview.scheduleIfStale("1:2", true), + }; + + // ② 가운데 꺾임점을 북동으로 10 m(job.drags[1]) + const second = first.map((node, i) => (i === b.index ? [node[0] + b.dx, node[1] + b.dy] : node)); + bridge.edited(draftOf(second)); + const runTwo = await settle(runs().length + 1); + await tick(job.haulDelayMs + 20); + const two = { + outcome: runTwo.outcome, + length: pipe.output("route").lengthM, + stale: staleOf(P, pipe), + reused: runTwo.stages.cross !== undefined, + pendingAfter: preview.scheduleIfStale("1:2", true), + pendingSaved: preview.scheduleIfStale("", true), + }; + + // ③ 그 차례 cross 가 끝내 안 와도(단계 실패) 3 s 뒤 풂 · 다시 그림 + const store = storeMod.useDesignStore(P, null); + const before = applied; + store.set("generation", pipe.generation + 100); + const stuck = preview.scheduleIfStale("1:2", true); + await tick(3100); + const released = { + stuck, + after: preview.scheduleIfStale("1:2", true), + redrew: applied - before, + }; + preview.dispose(); + pageMod.disposePagePipeline(P); + page.clearDrafts(P); + return { pendingDuring, one, two, released, savedLength: savedBase.chainage.at(-1) }; +} + +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_317_3d_update.py b/resources/tester/test_317_3d_update.py index 518c79650..125f2a936 100644 --- a/resources/tester/test_317_3d_update.py +++ b/resources/tester/test_317_3d_update.py @@ -55,7 +55,10 @@ def test_same_stale_key_schedules_once(result: dict) -> None: def test_draw_and_button_wiring() -> None: panel = _read("A00_Common", "design", "design_profile.ts") # 노선 초안 동안(346-2 붙임)은 계산 묶음 cross 를 기다림 — 그 밖은 같은 낡음 한 번만 다시 셈 - assert "crossPreview.scheduleIfStale(key)" in panel and "onStaleDesigns: (key) =>" in panel + assert ( + "crossPreview.scheduleIfStale(key, store === preview)" in panel + and "onStaleDesigns: (key) =>" in panel + ) render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") # 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1) assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render diff --git a/resources/tester/test_349_0f_draft_stale.py b/resources/tester/test_349_0f_draft_stale.py new file mode 100644 index 000000000..a322fff67 --- /dev/null +++ b/resources/tester/test_349_0f_draft_stale.py @@ -0,0 +1,126 @@ +"""PLAN 349-0f — 노선 초안 동안 유토곡선 「다시 계산 중…」 머묾. + +· 두 번 끌기(2번째 꺾임점 서쪽 20 m = 길이 바뀜 · 가운데 꺾임점 북동 10 m) 뒤 새 노선 횡단 낡음 0 + (cross 단계가 바뀐 범위 밖 측점도 계획고가 바뀌었으면 다시 셈) +· 「다시 계산 중」 = 묶음 그 차례 cross 를 기다리는 동안만 · 끝나면 풂 · 남은 낡음은 곡선 + 그 측점(342-1) +· 그 차례 cross 가 끝내 안 와도 3 s 뒤 풂 · 다시 그림 +짝: `helper_349_0f_draft_stale.cjs` · 지형 · 노선 · 관 틀 = `test_346_2_pipeline_cross_page.py`. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, +) +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 +from shapely.geometry import LineString, Point # noqa: E402 +from test_346_2_pipeline_cross_page import ( # noqa: E402 + MIN_RADIUS_M, + INTERVAL_M, + ONES, + _fake, + _real, + _line, + _pipes, + _saved_detail, + _sections, + _types, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _drags(nodes: list[list[float]], line: list[list[float]]) -> list[dict]: + """① 2번째 꺾임점 서쪽 20 m · ② 25+0(500 m) 가까운 꺾임점 북동 10 m(짧은 노선은 가운데).""" + shape = LineString(line) + target = min(500.0, shape.length / 2) + inner = [i for i in range(2, len(nodes) - 1)] or [1] + second = min(inner, key=lambda i: abs(shape.project(Point(nodes[i])) - target)) + return [ + {"index": 1, "dx": -20.0, "dy": 0.0}, + {"index": second, "dx": 7.07, "dy": 7.07}, + ] + + +@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub7"]) +def r(request) -> dict: + with tempfile.TemporaryDirectory() as folder: + npz, nodes = request.param(Path(folder)) + line = _line(nodes) + sampler = DtmGridSampler.from_npz(npz) + pipes = _pipes(line) + saved = _sections(line, sampler, pipes) + options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None) + policy = design_ground_following_profile(saved["longitudinal"], options)[0]["policy"] + job = { + "nodes": nodes, + "line": line, + "minRadius": MIN_RADIUS_M, + "policy": policy, + "types": _types(), + "interval": INTERVAL_M, + "pipes": pipes, + "standard": STANDARD_CROSS_SECTION, + "conversion": ONES, + "detail": _saved_detail(saved), + "haulDelayMs": 20, + "drags": _drags(nodes, line), + } + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(dtm_grid_bytes(npz)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0f_draft_stale.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_길이_바뀐_끌기_뒤_낡음_0(r): + one = r["one"] + assert one["outcome"] == "done" + assert abs(one["length"] - r["savedLength"]) > 1 # 노선 길이 바뀜 + assert one["stale"] == [] + + +def test_가운데_꺾임점_끌기_뒤_낡음_0(r): + two = r["two"] + assert two["outcome"] == "done" and two["reused"] + assert two["stale"] == [] + + +def test_다시_계산_중은_그_차례_cross_까지만(r): + assert r["pendingDuring"] is True # 끌어 놓은 차례 — cross 기다림 + assert r["one"]["pendingAfter"] is False # cross 끝 — 남은 낡음은 곡선 + 그 측점 + assert r["two"]["pendingAfter"] is False + assert r["two"]["pendingSaved"] is False + + +def test_cross_가_안_와도_3초_뒤_풂(r): + released = r["released"] + assert released["stuck"] is True + assert released["after"] is False and released["redrew"] == 1