From ca8c88f8100f17e481c66f53fb29542489e53403 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:16:19 +0900 Subject: [PATCH] =?UTF-8?q?perf(=ED=9A=A1=EB=8B=A8):=20346-2=20=EA=B5=AD?= =?UTF-8?q?=EB=B6=80=20=EC=97=B0=EC=82=B0=20=C2=B7=20[=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EB=B0=98=EC=98=81]=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=C2=B7=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 다시 셈이 측점별 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)을 기억 — 같으면 앞 결과 그대로 · 계획고 ▲ 한 번 = 10 측점 중 2 측점만 다시 셈 · 결과는 통째 셈과 같음 - [전체 측점 반영] = 다시 셈 한 번(측점마다 66 × 66 돌던 것) · 지금 값을 「사용자 선택」으로 박는 동작 없앰(암 경계 다시 셈 포함 · 사용자 확정 10-16) - 횡단 카드: 보이는 카드만 바로 갈아 끼움 · 화면 밖은 쉬는 틈에 몇 장씩(경고칩 · 면적 합은 계산값이라 그대로) - 머묾 재현 · 국부 연산 · 흙 배분 열쇠 · 전체 반영 · 카드 시험(vite ssr 로 진짜 모듈 묶음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B06_Section/B06_Section_Cross_Refresh.ts | 22 +- .../B06_Section_UI_Page_Design_Sync.ts | 44 +-- B06_Section/B06_Section_UI_Section_Common.ts | 22 ++ B06_Section/B06_Section_UI_Section_View.ts | 40 +- resources/tester/helper_342_1_entry.ts | 28 ++ .../tester/helper_342_1_station_line.mjs | 347 ++++++++++++++++++ resources/tester/test_316_defaults.py | 5 +- resources/tester/test_342_1_station_line.py | 126 +++++++ 8 files changed, 588 insertions(+), 46 deletions(-) create mode 100644 resources/tester/helper_342_1_entry.ts create mode 100644 resources/tester/helper_342_1_station_line.mjs create mode 100644 resources/tester/test_342_1_station_line.py diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index e58b1b3e0..c4b1da7a5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -318,6 +318,12 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map(); + function refreshLocally(input: CrossRefreshInput): number[] | null { const { projectId, detail, edits } = input; const stored = readAlignment(detail.longitudinal); @@ -334,6 +340,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. + const standardKey = JSON.stringify(standard); // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 @@ -460,9 +467,21 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { } : null, }; + // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 + // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. + const samples = section.samples ?? []; + const fingerprint = `${standardKey}|${elevation}|${samples.length}|${JSON.stringify({ ...options, standard: null })}`; + const memo = memos.get(section); + if ( + memo?.samples === section.samples && + memo.fingerprint === fingerprint && + memo.design === previous + ) { + continue; + } let next; try { - next = computeCrossDesign(section.samples ?? [], elevation, options); + next = computeCrossDesign(samples, elevation, options); } catch { continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). } @@ -477,6 +496,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const own = next[`berm_depth_${side}_m`]; if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; } + memos.set(section, { samples: section.samples, fingerprint, design: section.design }); updated.push(section.chainage_m); } return updated; diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index b406d57c9..804307026 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -117,28 +117,13 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); } - /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ - function changeFromDesign(chainageM: number): CrossDesignChange | null { - const target = ctx - .detail() - ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); - const design = target?.design; - if (!design) return null; - return { - ground_type: design.ground_type, - section_mode: design.section_mode, - ditch_side: design.ditch_side ?? null, - ditch_type: design.ditch_type ?? "standard", - paved: design.paved, - two_stage_slope: design.two_stage_slope ?? false, - ditch_choice: design.ditch_choice ?? null, - }; - } - - /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */ + /** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 다시 셈 — 지금 값을 「사용자 선택」으로 + * 박지 않음(자동 판정이 굳으면 노선이 바뀌어도 재판정 안 됨 · 346-2). 암 경계는 세션에서 읽음. */ function recomputeIfRock(chainageM: number): void { - const change = changeFromDesign(chainageM); - if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); + const design = ctx + .detail() + ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design; + if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true }); } /** @@ -215,23 +200,18 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { void reconcileStaleDesigns({ force: true }); } - /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. - * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 - * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + /** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면 + * 66 × 66 = 4.1 s 였음. 표준값은 다시 셈이 `effectiveStandardCross` 로 읽고, 측점별 선택은 + * 세션에 있는 것만 씀(지금 값을 「사용자 선택」으로 박지 않음 · 사용자 확정 10-16). */ let applyingAll = false; async function applyPanelToAll(): Promise { const sectionDetail = ctx.detail(); if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return; - const targets = sectionDetail.cross_sections.filter((section) => section.design); - if (!targets.length) return; - // 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장). - // 대신 도는 동안 다시 누르는 것만 막는다. + if (!sectionDetail.cross_sections.some((section) => section.design)) return; + // 화면을 잠그지 않는다(CLAUDE.md 5장) — 도는 동안 다시 누르는 것만 막는다. applyingAll = true; try { - for (const section of targets) { - const change = changeFromDesign(section.chainage_m); - if (change) await handleDesignChange(section.chainage_m, change); - } + await reconcileStaleDesigns({ force: true }); showToast(L("B06_Std_ApplyAll_Success"), "success"); } finally { applyingAll = false; diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 3d8a3a94e..529736949 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -133,6 +133,28 @@ export function staleKey( .join("|"); } +/** + * 쉬는 틈 일거리 줄(346-2) — 화면 밖 횡단 카드를 몇 장씩 갈아 끼움(카드 하나 ≈ 8 ms · 0.54 s / 66). + * 줄에 든 측점은 `run` 이 꺼내 그림 · 통째로 다시 그리면 `queue.clear()`. + */ +export function createIdleQueue(run: (chainageM: number) => void, batch = 6) { + const queue = new Set(); + let waiting = 0; + const drain = (): void => { + if (waiting || !queue.size) return; + const idle = window.requestIdleCallback ?? ((next: () => void) => window.setTimeout(next, 16)); + waiting = idle(() => { + waiting = 0; + for (const chainageM of [...queue].slice(0, batch)) { + queue.delete(chainageM); + run(chainageM); + } + drain(); + }); + }; + return { queue, drain }; +} + /** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */ export function staleChainages(key: string): number[] { return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88a6567b7..32fd338c1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -43,7 +43,7 @@ import { naturalHeightAtWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; -import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { CROSS_GRID_GAP, CROSS_HEIGHT, createIdleQueue } from "./B06_Section_UI_Section_Common"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -415,8 +415,11 @@ export function createSectionView( }); } + /** 화면 밖에서 아직 옛 그림인 카드 — 쉬는 틈에 몇 장씩(346-2). */ + const offscreen = createIdleQueue((chainageM) => rebuildCard(chainageM)); const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; + offscreen.queue.clear(); // 통째로 다시 그림 — 밀린 카드도 새 그림 const detail = currentDetail; cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); @@ -488,17 +491,23 @@ export function createSectionView( syncProfile(); }; - /** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */ - const rebuildCard = (chainageM: number): boolean => { - if (!currentDetail) return false; - const section = currentDetail.cross_sections.find( + /** 측점의 자료 · 카드 — 측점이 없으면 null. */ + const cardAt = (chainageM: number) => { + const section = currentDetail?.cross_sections.find( (candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01, ); - if (!section) return false; - const existing = document.getElementById(`cross-${section.station_id}`); + return section && { section, el: document.getElementById(`cross-${section.station_id}`) }; + }; + + /** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */ + const rebuildCard = (chainageM: number): boolean => { + const card = cardAt(chainageM); + if (!card) return false; + offscreen.queue.delete(chainageM); // 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다. - if (existing) - existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id))); + card.el?.replaceWith( + buildCrossCard(card.section, cachedRowHeight.get(card.section.station_id)), + ); return true; }; @@ -515,7 +524,18 @@ export function createSectionView( */ const refreshCards = (chainages: ReadonlyArray, redraw = true): void => { let touched = false; - for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true; + // 보이는 카드 먼저(346-2) — 화면 밖 카드는 쉬는 틈에 나눠 그림 · 경고칩 · 면적 합은 계산값이라 그대로 + const view = cards.getBoundingClientRect(); + for (const chainageM of chainages) { + const card = cardAt(chainageM); + if (!card) continue; + touched = true; + const rect = card.el?.getBoundingClientRect(); + if (rect && rect.height > 0 && rect.bottom >= view.top && rect.top <= view.bottom) + rebuildCard(chainageM); + else offscreen.queue.add(chainageM); + } + offscreen.drain(); if (!touched || !currentDetail) return; if (redraw) redrawProfile(); else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval); diff --git a/resources/tester/helper_342_1_entry.ts b/resources/tester/helper_342_1_entry.ts new file mode 100644 index 000000000..c9ec78270 --- /dev/null +++ b/resources/tester/helper_342_1_entry.ts @@ -0,0 +1,28 @@ +/* 342-1 · 346-2 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. + * 화면 · 서버 없이 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] 을 잰다. + */ +export { + holdScreenAlignment, + refreshCrossDesigns, + staleAgainstScreen, +} from "../../B06_Section/B06_Section_Cross_Refresh"; +export { + createIdleQueue, + staleChainages, + staleKey, +} from "../../B06_Section/B06_Section_UI_Section_Common"; +export { + buildAlignment, + emptyEdits, + toAlignmentBase, +} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +export { toDesignProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Data"; +export { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station"; +export { + irregularGraphStations, + maxChainageOf, +} from "../../B05_Profile/B05_Profile_UI_Profile_Layout"; +export { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview"; +export { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; +export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; +export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/helper_342_1_station_line.mjs b/resources/tester/helper_342_1_station_line.mjs new file mode 100644 index 000000000..e336a91a2 --- /dev/null +++ b/resources/tester/helper_342_1_station_line.mjs @@ -0,0 +1,347 @@ +/* 342-1 · 346-2 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_342_1_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_342_1_station_line.mjs <묶음.mjs> <입력.json> + * 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 } · 화면 선 = design_profile.ts 의 진짜 + * `listed()` 본문(소스에서 떼어 트랜스파일 · helper_177_3 방식). + */ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); +const require = createRequire(import.meta.url); +const ts = require(join(ROOT, "config", "node_modules", "typescript")); + +const store = () => { + 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(), + keys: () => [...m.keys()], + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +// 토스트 등 DOM 을 건드리는 자리 — 무엇을 불러도 받아 주는 빈 객체 +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +const warns = []; +console.warn = (...args) => warns.push(args.map(String).join(" ")); + +const [bundlePath, inputPath] = process.argv.slice(2); +const m = await import(pathToFileURL(bundlePath).href); +const input = JSON.parse(readFileSync(inputPath, "utf8")); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + +const PID = "p342"; +const RID = 7; +m.writeState("std-cross", input.standard, PID); + +/* ── 견본 노선 0~160 m — 서버 비정규 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 ── */ +const SERVER_IRR = 85.054; +const PIPE = 85.05; +const regular = [0, 20, 40, 60, 80, 100, 120, 140, 160]; +const serverStations = [...regular, SERVER_IRR] + .sort((a, b) => a - b) + .map((c) => ({ + station_id: `s${c}`, + chainage_m: c, + kind: c === SERVER_IRR ? "irregular" : c === 0 ? "bp" : "regular", + label: String(c), + ...(c === SERVER_IRR ? { structure: "파형강관 D800" } : {}), + })); +const groundAt = (c) => 100 + 0.08 * c - 6 * Math.sin(c / 23); +const chainage = Array.from({ length: 161 }, (_, i) => i); +const basePvi = serverStations.map((s, i) => ({ + chainage_m: s.chainage_m, + elevation_m: groundAt(s.chainage_m) + (i % 2 ? 1.6 : -1.3), +})); +const base = { + policy: input.policy, + onlyExplicitCurves: false, + basePvi, + chainage, + ground: chainage.map(groundAt), + stations: serverStations.map((s) => ({ station_id: s.station_id, chainage_m: s.chainage_m })), +}; +const stored = m.buildAlignment(base, m.emptyEdits()); +const crossSamples = (c) => + Array.from({ length: 49 }, (_, i) => { + const offset = -12 + i * 0.5; + return { offset_m: offset, elevation_m: groundAt(c) + 0.3 * offset, valid: true }; + }); +const freshDesign = () => ({ + design_elevation_m: 0, + ground_type: "soil", + section_mode: "left_cut", + ditch_side: "left", + ditch_type: "standard", + two_stage_slope: false, +}); +const makeDetail = () => ({ + longitudinal: { + length_m: 160, + stations: serverStations.map((s) => ({ ...s })), + samples: chainage.map((c) => ({ chainage_m: c, elevation_m: groundAt(c), valid: true })), + profile_alignment: JSON.parse(JSON.stringify(stored)), + design_profiles: [m.toDesignProfile(stored, undefined)], + }, + cross_sections: serverStations.map((s) => ({ + station_id: s.station_id, + chainage_m: s.chainage_m, + samples: crossSamples(s.chainage_m), + design: freshDesign(), + })), +}); +const irregularStations = [ + { id: "pipe-1", station: 4, remainder: 5.05, chainage_m: PIPE, structure: "파형강관 D800" }, +]; + +/* 화면 선 = design_profile.ts 의 진짜 listed() */ +const dpSrc = readFileSync(join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8"); +const listedSrc = dpSrc.match(/\r?\n function listed\(\)[\s\S]*?\r?\n \}\r?\n/)[0]; +const listedOf = new Function( + "storedBase", + "detail", + "irregularStations", + "oneStationList", + "irregularGraphStations", + "maxChainageOf", + "followMovedNodes", + `${transpile(listedSrc)}\nreturn listed();`, +); +const screenBase = (detail) => + listedOf( + m.toAlignmentBase(stored), + detail, + irregularStations, + m.oneStationList, + m.irregularGraphStations, + m.maxChainageOf, + m.followMovedNodes, + ); +const staleOf = (detail, line) => + m.staleKey({ + longitudinal: { design_profiles: [m.toDesignProfile(line, undefined)] }, + cross_sections: detail.cross_sections, + }); +const refresh = (detail, extra = {}) => + m.refreshCrossDesigns({ + projectId: PID, + routeId: RID, + detail, + edits: m.emptyEdits(), + ...extra, + }); + +const out = {}; + +/* 1. 머묾 재현 — 고치기 전 길(저장분 선) 대 화면 선 */ +{ + const detail = makeDetail(); + await refresh(detail, { alignment: stored }); // 저장본 = 서버가 저장분 선으로 낸 설계 + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + out.screenStations = screen.stations.map((s) => s.chainage_m); + out.staleBefore = m.staleChainages(staleOf(detail, screen)); + await refresh(detail); // 옛 길 — 맡긴 선 없음 → 저장분 선으로 다시 셈 + out.staleOldPath = m.staleChainages(staleOf(detail, screen)); + const release = m.holdScreenAlignment((id) => (id === RID ? screen : null)); + out.staleAgainstScreenBefore = m.staleAgainstScreen(detail, RID); + await refresh(detail); // 새 길 — 종단도가 맡긴 화면 선(B06 · 3D 와 같은 길) + out.staleNewPath = m.staleChainages(staleOf(detail, screen)); + out.staleAgainstScreenAfter = m.staleAgainstScreen(detail, RID); + // B06 판정(다시 셈이 얹은 design_profiles)과 B05 판정(화면 선)이 같은가 + out.staleB06View = m.staleChainages( + m.staleKey({ + longitudinal: detail.longitudinal, + cross_sections: detail.cross_sections, + }), + ); + release(); +} + +/* 2. 국부 연산 — 같은 입력 다시 셈 0 · 계획고 ▲ 한 번 = 몇 측점만 · 결과는 통째 셈과 같음 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + await refresh(detail, { alignment: screen }); + await refresh(detail, { alignment: screen }); // 첫 셈이 결과 칸(측구 유무 등)을 채운 뒤 한 번 더 — 저장본 상태 + const ids = () => detail.cross_sections.map((s) => s.design); + let before = ids(); + await refresh(detail, { alignment: screen }); + out.recomputedSame = ids().filter((d, i) => d !== before[i]).length; + const edited = m.buildAlignment(screenBase(detail), { + station_offsets: { "140.000": 0.3 }, + curve_radii: {}, + }); + before = ids(); + const changed = await refresh(detail, { alignment: edited }); + out.recomputedEdit = ids().filter((d, i) => d !== before[i]).length; + out.changedEdit = changed.length; + out.total = detail.cross_sections.length; + // 통째 셈(새 측점 객체 = 기억 없음)과 결과 같음 + const whole = makeDetail(); + whole.cross_sections.forEach((s, i) => (s.design = { ...detail.cross_sections[i].design })); + await refresh(whole, { alignment: stored }); + await refresh(whole, { alignment: edited }); + out.sameAsWhole = + JSON.stringify(whole.cross_sections.map((s) => s.design)) === + JSON.stringify(detail.cross_sections.map((s) => s.design)); +} + +/* 3. 다시 셈 뒤에도 남은 낡음 — 기다림(true) → 남음(false) · 콘솔 경고 · 다시 그림 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + await refresh(detail, { alignment: screen }); + const broken = detail.cross_sections[3]; + broken.samples = []; // 다시 셈이 못 고치는 측점 + broken.design.design_elevation_m += 1; + const applied = []; + const preview = m.createCrossPreview({ + projectId: PID, + detail: () => detail, + routeId: () => RID, + edits: () => m.emptyEdits(), + alignment: () => screen, + debounceMs: () => 0, + onApplied: (updated) => applied.push(updated.length), + }); + const key = staleOf(detail, screen); + out.settleFirst = preview.scheduleIfStale(key); + await sleep(50); + const again = staleOf(detail, screen); + out.settleSameKey = again === key; + out.settleAfter = preview.scheduleIfStale(again); + out.settleWarns = warns.filter((w) => w.includes("낡은 측점")).length; + out.settleRedraws = applied.length; + out.settleClear = preview.scheduleIfStale(""); + preview.dispose(); +} + +/* 4. 흙 배분 — 실패한 열쇠 다시 보냄 · 받은 열쇠는 다시 안 물음 */ +{ + const replies = [false, true, true]; + const posts = []; + globalThis.fetch = async (_url, init) => { + posts.push(JSON.parse(init.body).result.points.length); + const ok = replies.shift() ?? true; + return { + ok, + json: async () => ({ haul_plan: { tag: JSON.parse(init.body).result.points.length } }), + }; + }; + let ready = 0; + const prefetch = m.createHaulPlanPrefetch( + PID, + () => RID, + () => (ready += 1), + ); + const A = { points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }] }; + const B = { + points: [{ cumulative_volume_m3: 1 }, { cumulative_volume_m3: 2 }, { cumulative_volume_m3: 3 }], + }; + prefetch.schedule(A); + await sleep(450); + out.haulAfterFail = posts.length; // 1 — 실패 + prefetch.schedule(A); // 같은 열쇠 — 다시 보냄 + await sleep(450); + out.haulRetry = posts.length; // 2 + prefetch.schedule(B); + await sleep(450); + out.haulB = posts.length; // 3 + prefetch.schedule(A); // 받아 둔 열쇠 — 안 물음 + await sleep(450); + out.haulKept = posts.length; // 3 + out.haulKeptPlan = prefetch.current()?.tag; // 2 (A 의 점 2개) + out.haulReady = ready; + prefetch.dispose(); +} + +/* 5. [전체 측점 반영] — 다시 셈 한 번 · 측점 선택을 세션에 안 박음 */ +{ + const detail = makeDetail(); + const screen = m.buildAlignment(screenBase(detail), m.emptyEdits()); + const release = m.holdScreenAlignment((id) => (id === RID ? screen : null)); + const cardsCalls = []; + const sync = m.createDesignSync({ + projectId: PID, + routeId: () => RID, + detail: () => detail, + view: () => ({ + refreshCards: (updated) => cardsCalls.push(updated.length), + refreshCard: () => cardsCalls.push(-1), + detailChart: () => null, + }), + }); + const t0 = performance.now(); + await sync.applyPanelToAll(); + out.applyAllMs = performance.now() - t0; + out.applyAllCalls = cardsCalls; + out.applyAllChoiceKeys = sessionStorage.keys().filter((k) => k.includes("crossdesign")).length; + release(); +} + +/* 6. 횡단 카드 보이는 것 먼저 — B06_Section_UI_Section_View.ts 의 진짜 refreshCards 본문 */ +{ + const viewSrc = readFileSync(join(ROOT, "B06_Section", "B06_Section_UI_Section_View.ts"), "utf8"); + const body = viewSrc.match(/\r?\n const refreshCards = [\s\S]*?\r?\n \};\r?\n/)[0]; + const rebuilt = []; + let redraws = 0; + const top = { 0: 0, 20: 150, 40: 300, 60: 450, 80: 600 }; // 카드 위치(px) · 화면 = 0~320 + const factory = new Function( + "cards", + "cardAt", + "rebuildCard", + "offscreen", + "currentDetail", + "redrawProfile", + "fillSlopeNotice", + "cachedStationInterval", + `${transpile(body)}\nreturn refreshCards;`, + ); + let offscreen; + const rebuildCard = (c) => { + offscreen.queue.delete(c); + rebuilt.push(c); + return true; + }; + offscreen = m.createIdleQueue(rebuildCard, 2); + const refreshCards = factory( + { getBoundingClientRect: () => ({ top: 0, bottom: 320 }) }, + (c) => ({ + section: {}, + el: { getBoundingClientRect: () => ({ top: top[c], bottom: top[c] + 140, height: 140 }) }, + }), + rebuildCard, + offscreen, + { cross_sections: [] }, + () => (redraws += 1), + { update: () => undefined }, + 20, + ); + refreshCards([80, 0, 60, 20, 40]); + out.cardsNow = [...rebuilt]; + out.cardsRedraws = redraws; + await sleep(120); + out.cardsAll = [...rebuilt]; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py index 086d0d9b5..6c0175b12 100644 --- a/resources/tester/test_316_defaults.py +++ b/resources/tester/test_316_defaults.py @@ -80,9 +80,8 @@ def test_ts_defaults_off_and_stored_kept(): assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( "B06_Section/B06_Section_Cross_Refresh.ts" ) - assert "two_stage_slope: design.two_stage_slope ?? false," in _read( - "B06_Section/B06_Section_UI_Page_Design_Sync.ts" - ) + # 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만 + assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts") def test_3d_first_view_perspective(): diff --git a/resources/tester/test_342_1_station_line.py b/resources/tester/test_342_1_station_line.py new file mode 100644 index 000000000..1dfd11def --- /dev/null +++ b/resources/tester/test_342_1_station_line.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""342-1 · 346-2 — 계획선 한 벌 · 낡음 머묾 · 국부 연산 · 흙 배분 열쇠 · [전체 측점 반영] · 카드. + +못박는 것 + - 머묾 재현: 서버 측점 85.054(0.001 m) · 관 지점 85.05(0.01 m) · 종단곡선 안 — 저장분 선으로 + 다시 세면 화면 선 기준 낡음이 남음(옛 길) · 화면 선을 받으면 낡음 0 · B05 · B06 판정 같음. + - 국부 연산: 같은 입력 다시 셈 0 측점 · 계획고 ▲ 한 번 = 곡선 둘레 몇 측점 · 결과 = 통째 셈. + - 다시 셈 뒤에도 남은 낡음 = 기다림 끝(false) · 콘솔 경고 한 줄 · 다시 그림(곡선은 그림). + - 흙 배분: 실패한 열쇠 다시 보냄 · 받아 둔 열쇠는 다시 안 물음. + - [전체 측점 반영] = 다시 셈 한 번 · 측점 선택을 세션에 안 박음. + - 횡단 카드: 보이는 카드만 바로 · 나머지는 쉬는 틈에. + +Node 헬퍼 `helper_342_1_station_line.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_342_1_entry.ts`)을 +부름. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + +FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json" + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b342") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_342_1_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_342_1_entry.js", bundle) + policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"] + source = work / "input.json" + source.write_text( + json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str), + encoding="utf-8", + ) + proc = subprocess.run( + ["node", str(HERE / "helper_342_1_station_line.mjs"), str(bundle), str(source)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_화면_선은_관_지점으로_갈음(out): + assert 85.05 in out["screenStations"] and 85.054 not in out["screenStations"] + + +def test_머묾_재현_옛_길은_낡음이_남음(out): + assert out["staleBefore"] == [85.054] + assert out["staleOldPath"] == [85.054] + assert out["staleAgainstScreenBefore"] is True + + +def test_화면_선을_받으면_낡음_0_두_판정_같음(out): + assert out["staleNewPath"] == [] + assert out["staleAgainstScreenAfter"] is False + assert out["staleB06View"] == [] + + +def test_국부_연산_바뀐_측점만(out): + assert out["recomputedSame"] == 0 + assert 0 < out["recomputedEdit"] < out["total"] + assert out["sameAsWhole"] is True + + +def test_남은_낡음은_곡선을_그리고_경고(out): + assert out["settleFirst"] is True # 다시 셈 기다림 + assert out["settleSameKey"] is True # 다시 셈이 못 고침 + assert out["settleAfter"] is False # 기다림 끝 — 곡선 그림 + assert out["settleWarns"] == 1 + assert out["settleRedraws"] >= 1 # 바뀐 것 없어도 다시 그림 + assert out["settleClear"] is False + + +def test_흙_배분_실패_열쇠_다시_보냄_받은_열쇠는_안_물음(out): + assert out["haulAfterFail"] == 1 + assert out["haulRetry"] == 2 + assert out["haulB"] == 3 + assert out["haulKept"] == 3 + assert out["haulKeptPlan"] == 2 + + +def test_전체_측점_반영은_한_번_선택_안_박음(out): + assert out["applyAllCalls"] == [out["total"]] + assert out["applyAllChoiceKeys"] == 0 + + +def test_횡단_카드_보이는_것_먼저(out): + assert out["cardsNow"] == [0, 20, 40] + assert out["cardsRedraws"] == 1 + assert sorted(out["cardsAll"]) == [0, 20, 40, 60, 80]