From 0ffabd6fbf45541b1cfd1234a862ef9f351e12d5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 07:21:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(468=20=EA=B0=92=20=EC=95=88=20=EB=B3=80?= =?UTF-8?q?=ED=95=A8=20=E2=91=A2):=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=20=EB=B9=88=20=EC=B9=B8=20=C2=B7=20=EC=A0=91=ED=9E=98=20?= =?UTF-8?q?=ED=8C=8C=EC=83=9D=EA=B0=92=20=C2=B7=20=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EC=86=8C=EC=88=98=20=ED=95=9C=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사진첩: 「{}」 · 「null」 · 「""」 = 칸 없음 — 빈 맵 쓰기가 걸음이 되어 ↷ 를 버리던 흠(R4) · 「[]」(관 · 구조물 모두 지움)은 값으로 둠 - 세션 맵 · 연동 플래그: 빈 맵 persist = 칸 지움(갈아끼움 이동이 「{}」 여섯 칸을 만들지 않음) - 종단도: 본문 0 크기(판 접힘)에도 낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료를 셈 · 그리기만 건너뜀 — 떼어 낸 유토 판은 그림 - 배지: 소수 한 자리 고정(−17,570.0㎥) - 시험: test_272_history_empty_cells · test_272_hidden_summary(옛 코드 7 실패) Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_UI_Profile_History.ts | 12 +- .../B05_Profile_UI_Profile_MassHaul.ts | 4 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 150 +++++--- .../B06_Section_UI_Page_Link_Session.ts | 15 +- common_util/common_util_mass_haul_badge.ts | 4 +- resources/tester/helper_272_entry.ts | 11 + resources/tester/helper_272_value_hold.mjs | 353 ++++++++++++++++++ resources/tester/test_272_hidden_summary.py | 55 +++ .../tester/test_272_history_empty_cells.py | 104 ++++++ 9 files changed, 643 insertions(+), 65 deletions(-) create mode 100644 resources/tester/helper_272_entry.ts create mode 100644 resources/tester/helper_272_value_hold.mjs create mode 100644 resources/tester/test_272_hidden_summary.py create mode 100644 resources/tester/test_272_history_empty_cells.py diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index a59c35753..1922c6f7d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -45,11 +45,19 @@ const isEditKey = (key: string, projectId: string): boolean => /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; +/** 빈 칸 = 칸 없음(272 · 떠남 확인 `filled` 와 한 규칙) — 빈 맵을 쓴 것은 걸음이 아님. + * 「[]」 는 목록 초안(관 · 구조물 모두 지움 ≠ 저장분)이라 값으로 찍음. */ +const EMPTY_CELLS = new Set(["", "{}", "null"]); +const cellOf = (key: string): string | null => { + const value = readByKey(key); + return value === null || EMPTY_CELLS.has(value) ? null : value; +}; + /** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */ function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot { const snapshot: HistorySnapshot = {}; for (const key of editKeys(projectId)) { - const value = readByKey(key); + const value = cellOf(key); if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value; } return snapshot; @@ -255,7 +263,7 @@ export function createProfileHistory( stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습 }; seedActive = (key) => { - const value = isEditKey(key, projectId) ? readByKey(key) : null; + const value = isEditKey(key, projectId) ? cellOf(key) : null; if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value)); }; active = history; diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index a24eda3d4..d6f267972 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -139,6 +139,8 @@ export interface RouteMassHaulDrawParams { pendingRecalc?: boolean; /** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */ staleChainages?: number[]; + /** 종단 본문이 0 크기(판 접힘) — 이 판도 자리가 없으면 총괄값만(떼어 낸 창이면 그림 · 272). */ + bodyHidden?: boolean; } export interface RouteMassHaulPanel { @@ -339,7 +341,7 @@ export function createRouteMassHaulPanel( syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번). - if (!open) { + if (!open || (params.bodyHidden && overlay.clientWidth <= 0)) { reportSummary(params); return; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 08d7b87d1..499f85633 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -128,11 +128,101 @@ export interface ProfileRenderContext { setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void; } -/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */ +/** 그림과 상관없는 파생값 — 낡음 예약 · 측점 자료(272 · 접힘 · 0 크기에도 같은 값). */ +function deriveProfile( + ctx: ProfileRenderContext, + detail: SectionDetailResponse, + alignment: ProfileAlignment | null, + irregularStations: IrregularStation[], +) { + const longitudinal = detail.longitudinal; + const designProfiles = alignment + ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] + : (longitudinal.design_profiles ?? []); + // 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1) + const stale = staleKey({ + longitudinal: { design_profiles: designProfiles }, + cross_sections: detail.cross_sections, + }); + const pending = ctx.onStaleDesigns?.(stale) ?? stale !== ""; + // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. + const graphData = normalizedLongitudinal(longitudinal); + // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m + // 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`). + const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal)); + const graphLongitudinal = { + ...graphData, + stations: oneStationList(graphData.stations, injected), + }; + return { designProfiles, stale, pending, graphData, graphLongitudinal }; +} +type DerivedProfile = ReturnType; + +/** 유토곡선에 넘길 값 — 그림과 숨은 셈이 한 벌(가로 배치만 다름). */ +function massHaulParamsOf( + ctx: ProfileRenderContext, + detail: SectionDetailResponse, + derived: DerivedProfile, + view: { originOffset: number; fromM: number; toM: number; widthPx: number }, + stationIntervalM: number | undefined, + bodyHidden: boolean, +): RouteMassHaulDrawParams { + const { longitudinal } = detail; + const { designProfiles, stale, pending } = derived; + return { + stationSource: derived.graphLongitudinal, + // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 + // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. + longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, + crossSections: detail.cross_sections, + // 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이 + // 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). + // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 + // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 + // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). + pendingRecalc: pending, + staleChainages: pending ? [] : staleChainages(stale), + axis: { + maxChainageM: maxChainageOf(longitudinal), + // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 + // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. + padLeft: LONG_PAD.left + view.originOffset, + padRight: LONG_PAD.right + view.originOffset, + // 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게. + axisX: LONG_PAD.left, + // 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다 + // (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음). + padTop: 30, + // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 + // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). + viewRange: { fromM: view.fromM, toM: view.toM }, + }, + stationInterval: stationIntervalM ?? 1, + widthPx: view.widthPx, + selectedStationId: ctx.selectedStationId(), + onSelectStation: ctx.selectStation, + bodyHidden, + }; +} + +/** 본문을 통째로 다시 그린다. 상세가 없으면 아무것도 하지 않는다. + * 본문이 0 크기(판 접힘 · 아직 자리 없음)면 파생값(낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료)만 + * 셈 · 그리기만 건너뜀 — 펼침 · 떼어 낸 유토 창과 같은 값(272). */ export function renderProfile(ctx: ProfileRenderContext): void { const { body, callbacks, massHaul, tableOverlay, store } = ctx; const detail = ctx.detail(); - if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; + if (!detail) return; + if (body.clientWidth <= 0 || body.clientHeight <= 0) { + const alignment = ctx.alignment(); + const derived = deriveProfile(ctx, detail, alignment, ctx.irregularStations()); + if (alignment && derived.designProfiles[0]) { + const maxM = maxChainageOf(detail.longitudinal); + const view = { originOffset: 0, fromM: 0, toM: maxM, widthPx: 1 }; + const interval = ctx.stationInterval() ?? alignment.policy.station_interval_m; + massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, interval, true)); + } + return; + } const alignment = ctx.alignment(); const base = ctx.base(); const stationInterval = ctx.stationInterval(); @@ -252,24 +342,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { intervalM: stationIntervalM ?? 20, }); }); - const designProfiles = alignment - ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] - : (longitudinal.design_profiles ?? []); - // 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1) - const stale = staleKey({ - longitudinal: { design_profiles: designProfiles }, - cross_sections: detail.cross_sections, - }); - const pending = ctx.onStaleDesigns?.(stale) ?? stale !== ""; - // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. - const graphData = normalizedLongitudinal(longitudinal); - // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m - // 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`). - const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal)); - const graphLongitudinal = { - ...graphData, - stations: oneStationList(graphData.stations, injected), - }; + const derived = deriveProfile(ctx, detail, alignment, irregularStations); + const { designProfiles, graphData, graphLongitudinal } = derived; // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. const toChainage = chainageInverter(longitudinal, width, originOffset); @@ -441,39 +515,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. if (alignment && designProfiles[0]) { - const massHaulParams: RouteMassHaulDrawParams = { - stationSource: graphLongitudinal, - // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 - // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. - longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, - crossSections: detail.cross_sections, - // 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이 - // 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). - // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 - // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 - // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). - pendingRecalc: pending, - staleChainages: pending ? [] : staleChainages(stale), - axis: { - maxChainageM: maxChainageOf(longitudinal), - // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 - // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. - padLeft: LONG_PAD.left + originOffset, - padRight: LONG_PAD.right + originOffset, - // 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게. - axisX: LONG_PAD.left, - // 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다 - // (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음). - padTop: 30, - // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 - // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). - viewRange: { fromM: viewFromM, toM: viewToM }, - }, - stationInterval: stationIntervalM ?? 1, - widthPx: width, - selectedStationId, - onSelectStation: ctx.selectStation, - }; - massHaul.draw(massHaulParams); + const view = { originOffset, fromM: viewFromM, toM: viewToM, widthPx: width }; + massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, stationIntervalM, false)); } } diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index bb8e83e9c..90077a288 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -35,7 +35,7 @@ export interface SessionMap { values: Map; /** 세션값 읽기 — 맵을 비우고 다시 채운다. */ load: () => void; - /** 맵을 통째로 세션에 쓴다. */ + /** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */ persist: () => void; } @@ -98,7 +98,8 @@ export function createSessionMap( const key = sessionKey(); if (!key) return; try { - writeByKey(key, JSON.stringify(Object.fromEntries(values))); + // 빈 맵 = 칸 지움(272) — 「{}」 를 남기면 사진첩이 걸음으로 봄 + writeByKey(key, values.size ? JSON.stringify(Object.fromEntries(values)) : null); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -144,10 +145,12 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag if (!key) return; writeByKey( key, - JSON.stringify({ - detached: Object.fromEntries(detached), - grade: Object.fromEntries(followGrade), - }), + detached.size || followGrade.size + ? JSON.stringify({ + detached: Object.fromEntries(detached), + grade: Object.fromEntries(followGrade), + }) + : null, // 둘 다 비면 칸 지움(272) ); }, }; diff --git a/common_util/common_util_mass_haul_badge.ts b/common_util/common_util_mass_haul_badge.ts index 93e9f2179..184d3430e 100644 --- a/common_util/common_util_mass_haul_badge.ts +++ b/common_util/common_util_mass_haul_badge.ts @@ -21,9 +21,9 @@ export interface MassHaulBadgeValues { shortageM3: number; } -/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */ +/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리 고정(0 도 「.0」 · 272). */ function volume(value: number): string { - return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`; + return `${value.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}㎥`; } export interface MassHaulBadge { diff --git a/resources/tester/helper_272_entry.ts b/resources/tester/helper_272_entry.ts new file mode 100644 index 000000000..8808c4f29 --- /dev/null +++ b/resources/tester/helper_272_entry.ts @@ -0,0 +1,11 @@ +/* 272 시험 묶음 진입점(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수(진짜 모듈 · vite ssr). */ +export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; +export { + createSessionMap, + moveSessionMaps, +} from "../../B06_Section/B06_Section_UI_Page_Link_Session"; +export { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; +export { createRouteMassHaulPanel } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; +export { createMassHaulBadge } from "../../common_util/common_util_mass_haul_badge"; +export { computeMassHaulSeries } from "../../common_util/common_util_mass_haul"; +export { staleKey } from "../../B06_Section/B06_Section_UI_Section_Common"; diff --git a/resources/tester/helper_272_value_hold.mjs b/resources/tester/helper_272_value_hold.mjs new file mode 100644 index 000000000..820064ab2 --- /dev/null +++ b/resources/tester/helper_272_value_hold.mjs @@ -0,0 +1,353 @@ +/* 272 헬퍼(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수. + * node helper_272_value_hold.mjs <묶음.mjs> + * vite ssr 로 묶은 진짜 모듈(helper_272_entry.ts) · 표준출력 JSON 한 벌. + * 짝 = `test_272_history_empty_cells.py` · `test_272_hidden_summary.py`. + */ +import { pathToFileURL } from "node:url"; + +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(), + }; +}; +// 손길 듣기(pointerdown · keydown)가 붙는 창 — 사진첩 시험(346-4)과 같은 꼴 +const win = new EventTarget(); +win.setTimeout = setTimeout; +win.clearTimeout = clearTimeout; +win.sessionStorage = store(); +win.localStorage = store(); +win.innerHeight = 900; +win.matchMedia = () => ({ matches: false, addEventListener: () => undefined }); +win.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.window = win; +globalThis.screen = {}; +globalThis.sessionStorage = win.sessionStorage; +globalThis.localStorage = win.localStorage; +globalThis.requestAnimationFrame = win.requestAnimationFrame; +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +localStorage.setItem("frd_current_project_id", "p1"); + +const m = await import(pathToFileURL(process.argv[2]).href); +const out = {}; +const S = sessionStorage; +const key = (name) => `aislo:draft:${name}:p1:7`; +const SIX = ["basinadjust", "crossw", "extraspan", "extrawall", "inletstruct", "revetx"]; +const touch = () => win.dispatchEvent(new Event("keydown")); + +/* ── H1 · 되돌린 뒤 빈 칸이 생김(R4) — 「{}」 · 「null」 · 「""」 쓰기는 걸음 아님 · ↷ 남음 ── */ +{ + const h = m.createProfileHistory(() => {}); + touch(); + S.setItem(key("alignment-draft"), JSON.stringify({ station_offsets: { 100: 1 } })); + h.record(); + const edited = h.canUndo(); + h.undo(); + const undone = { undo: h.canUndo(), redo: h.canRedo(), grade: S.getItem(key("alignment-draft")) }; + // B06 로 넘어가는 누름(손길) 뒤 첫 마운트 · 갈아끼움 이동(moveSessionMaps)이 빈 맵 여섯을 씀 · 다른 칸은 null · 빈 글 + touch(); + SIX.forEach((name) => S.setItem(key(name), "{}")); + S.setItem(key("rockb"), "null"); + S.setItem(key("cutslope"), ""); + h.record(); + out.h1 = { edited, undone, after: { undo: h.canUndo(), redo: h.canRedo() } }; + // ↷ = 사용자 조작 그대로 한 걸음 + out.h1.redone = h.redo(); + out.h1.redoGrade = S.getItem(key("alignment-draft")); + out.h1.final = { undo: h.canUndo(), redo: h.canRedo() }; + S.clear(); + localStorage.setItem("frd_current_project_id", "p1"); +} + +/* ── H2 · 「[]」 = 목록 초안(관 · 구조물 모두 지움) — 걸음 그대로 · 되돌리면 칸 없음 ── */ +{ + const h = m.createProfileHistory(() => {}); + touch(); + S.setItem("aislo:draft:pipes:p1", "[]"); + h.record(); + const step = h.canUndo(); + h.undo(); + out.h2 = { step, undonePipes: S.getItem("aislo:draft:pipes:p1") }; + h.redo(); + out.h2.redonePipes = S.getItem("aislo:draft:pipes:p1"); + S.clear(); + localStorage.setItem("frd_current_project_id", "p1"); +} + +/* ── H3 · 빈 칸이 있던 걸음으로 되돌림 = 빈 칸(없음과 같음) · 값 칸은 그대로 ── */ +{ + S.setItem(key("crossw"), "{}"); + S.setItem(key("revetx"), JSON.stringify({ "100.00:inlet": { x: 1 } })); + const h = m.createProfileHistory(() => {}); + touch(); + S.setItem(key("crossw"), JSON.stringify({ "100.00": 6 })); + h.record(); + const step = h.canUndo(); + h.undo(); + out.h3 = { step, crossw: S.getItem(key("crossw")), revetx: S.getItem(key("revetx")) }; + S.clear(); + localStorage.setItem("frd_current_project_id", "p1"); +} + +/* ── H4 · 세션 맵 — 빈 맵 쓰기 = 칸 지움 · 값 있으면 씀 · 빈 맵 이동은 칸을 안 만듦 ── */ +{ + const width = m.createSessionMap( + () => key("crossw"), + (v) => (typeof v === "number" ? v : undefined), + ); + width.persist(); + const emptyWrite = S.getItem(key("crossw")); + width.values.set("100.00", 6); + width.persist(); + const filledWrite = S.getItem(key("crossw")); + width.values.clear(); + width.persist(); + const clearedWrite = S.getItem(key("crossw")); + const others = SIX.filter((n) => n !== "crossw").map((n) => + m.createSessionMap( + () => key(n), + (v) => v, + ), + ); + m.moveSessionMaps(new Map([["100.00", "120.00"]])); + const moved = SIX.map((n) => S.getItem(key(n))); + // 값 있는 맵 이동 = 새 자리로 옮겨 씀 + S.setItem(key("extrawall"), JSON.stringify({ "100.00": 2 })); + m.moveSessionMaps(new Map([["100.00", "120.00"]])); + out.h4 = { + emptyWrite, + filledWrite, + clearedWrite, + moved, + movedFilled: S.getItem(key("extrawall")), + made: others.length, + }; + S.clear(); + localStorage.setItem("frd_current_project_id", "p1"); +} + +/* ── 숨은 종단도(본문 0 크기) — 낡음 예약 · 유토곡선 셈 · 배분 선반입 여전히 · 펼침과 같은 값 ── */ +const samples = [0, 20, 40, 60, 80, 100].map((c) => ({ + chainage_m: c, + elevation_m: 100 + c * 0.05, + valid: true, +})); +const plan = [0, 20, 40, 60, 80, 100].map((c) => ({ chainage_m: c, elevation_m: 100 + c * 0.04 })); +const design = (c, cut, fill, z) => ({ + station_id: `station_${c}`, + chainage_m: c, + design: { + cut_area_m2: cut, + fill_area_m2: fill, + ground_type: "soil", + design_elevation_m: z, + }, +}); +const detail = { + longitudinal: { + length_m: 100, + samples, + stations: samples.map((s) => ({ + station_id: `station_${s.chainage_m}`, + chainage_m: s.chainage_m, + })), + design_profiles: [{ id: "design_grade_line", samples: plan }], + }, + cross_sections: [ + design(0, 2, 0, 100), + design(20, 3, 1, 100.8), + design(40, 1, 4, 101.6), + design(60, 0, 6, 999), // 낡음 — 계획선과 어긋난 측점(다시 셈 예약 대상) + design(80, 2, 2, 103.2), + design(100, 4, 0, 104), + ], +}; +const alignment = { + samples: plan, + edits: { station_offsets: {}, curve_radii: {} }, + stations: plan.map((p) => ({ + chainage_m: p.chainage_m, + plan_elevation_m: p.elevation_m, + kind: "regular", + })), + curves: [], + balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 }, + policy: { station_interval_m: 20, edit_step_m: 0.1 }, +}; +const ONES = { + soil: { loose: 1, compacted: 1 }, + ripping_rock: { loose: 1, compacted: 1 }, + blasting_rock: { loose: 1, compacted: 1 }, +}; + +function ctxFor(width, rec) { + const body = { + clientWidth: width, + clientHeight: width ? 400 : 0, + scrollLeft: 0, + parentElement: null, + replaceChildren: () => (rec.painted += 1), + addEventListener: () => undefined, + }; + return { + body, + onStaleDesigns: (k) => (rec.stale.push(k), false), + callbacks: undefined, + massHaul: { draw: (p) => rec.mass.push(p) }, + tableOverlay: { isOpen: () => false, contentHeight: () => 0, setTable: () => undefined }, + store: { edits: () => ({}), savedRadii: () => ({}), resetStation: () => undefined }, + detail: () => detail, + alignment: () => alignment, + base: () => null, + stationInterval: () => 20, + irregularStations: () => [], + minCoverTargets: () => [], + structures: () => [], + structureTypes: () => [], + selectedStationId: () => null, + setSelectedStationId: () => undefined, + selectedStructureId: () => null, + setSelectedStructureId: () => undefined, + stationDisplay: () => ({ station: 0, cumulative: 0 }), + setLastSize: () => (rec.sized += 1), + renderBalance: () => (rec.balance += 1), + applyHeightCascade: () => ({ chartHeight: 300 }), + clearMainDragCooldown: () => undefined, + selectStation: () => undefined, + applyEdits: () => undefined, + handleToolPick: () => false, + zoom: () => ({ x: 1 }), + holdElevationRange: (next) => next ?? undefined, + toolActive: () => false, + tools: { selectedRuns: () => [], mountOverlay: () => undefined }, + stationIdAtStructure: () => null, + redraw: () => undefined, + setElevationWindowUpdater: () => undefined, + }; +} +const pick = (p) => + p && { + pendingRecalc: p.pendingRecalc, + staleChainages: p.staleChainages, + crossSections: p.crossSections.length, + longitudinal: p.longitudinal, + stations: p.stationSource.stations.map((s) => s.chainage_m), + maxChainageM: p.axis.maxChainageM, + }; +const hiddenRec = { stale: [], mass: [], painted: 0, sized: 0, balance: 0 }; +m.renderProfile(ctxFor(0, hiddenRec)); +const shownRec = { stale: [], mass: [], painted: 0, sized: 0, balance: 0 }; +let shownError = null; +try { + m.renderProfile(ctxFor(1200, shownRec)); +} catch (error) { + shownError = String(error?.stack ?? error); +} +out.hidden = { + stale: hiddenRec.stale, + mass: pick(hiddenRec.mass[0]), + massCount: hiddenRec.mass.length, + painted: hiddenRec.painted, + sized: hiddenRec.sized, +}; +out.shown = { + stale: shownRec.stale, + mass: pick(shownRec.mass[0]), + massCount: shownRec.mass.length, + painted: shownRec.painted, + error: shownError, +}; +out.expectedStale = m.staleKey({ + longitudinal: { design_profiles: [{ samples: plan }] }, + cross_sections: detail.cross_sections, +}); + +/* 유토곡선 판(진짜) — 판 접힘 · 펼침인데 본문 숨음(자리 0) = 총괄값 · 배분 선반입만 · 그림 없음 */ +function massPanel(open) { + localStorage.setItem("aislo:pref:masshaul-open", String(open)); + const rec = { summary: [], scheduled: 0 }; + const prefetch = { + schedule: () => (rec.scheduled += 1), + current: () => null, + }; + const panel = m.createRouteMassHaulPanel( + () => undefined, + (s) => rec.summary.push(s), + prefetch, + ); + panel.setContext({ conversion: ONES }); + return { panel, rec }; +} +const series = m.computeMassHaulSeries( + { length_m: 100, design_profiles: [{ samples: plan }] }, + detail.cross_sections, + ONES, + undefined, + ["cross"], +); +const finalM3 = series[0]?.result.points.at(-1)?.cumulative_volume_m3 ?? null; +const massParams = hiddenRec.mass[0]; +out.panel = {}; +for (const open of [false, true]) { + const { panel, rec } = massPanel(open); + let error = null; + try { + if (massParams) panel.draw({ ...massParams, pendingRecalc: false }); + } catch (e) { + error = String(e?.stack ?? e); + } + out.panel[open ? "open" : "closed"] = { + summary: rec.summary.map((s) => s?.finalM3 ?? null), + scheduled: rec.scheduled, + error, + }; +} +out.finalM3 = finalM3; + +/* ── 배지 표기 — 소수 한 자리 고정 ── */ +{ + const make = () => { + const el = { + textContent: "", + title: "", + hidden: false, + style: { setProperty: () => undefined }, + classList: { toggle: () => undefined, add: () => undefined }, + append: () => undefined, + className: "", + }; + return el; + }; + const made = []; + globalThis.document = { createElement: () => (made.push(make()), made.at(-1)) }; + const badge = m.createMassHaulBadge(); + const texts = []; + for (const v of [-17570, -17570.7, 1234.56, 0]) { + badge.set({ + finalM3: v, + cutNaturalM3: 0, + cutCompactedM3: 0, + fillCompactedM3: 0, + surplusM3: 0, + shortageM3: 0, + }); + texts.push(made[2].textContent); + } + out.badge = texts; + globalThis.document = any; +} + +process.stdout.write(JSON.stringify(out)); +process.exit(0); diff --git a/resources/tester/test_272_hidden_summary.py b/resources/tester/test_272_hidden_summary.py new file mode 100644 index 000000000..9514a283e --- /dev/null +++ b/resources/tester/test_272_hidden_summary.py @@ -0,0 +1,55 @@ +"""272 · 468 (C2) 종단 판 접힘(본문 0 크기) 파생값 · (C3) 배지 자릿수. + +R3 — 판 접은 채 교점 ↓ → 배지 옛 값 그대로 → 이제 접힘 동안도 낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료를 셈 · +그리기(본문 갈아 끼움 · 크기 기록)만 건너뜀 · 펼친 그림과 같은 값. 배지 = 소수 한 자리 고정. +짝: `helper_272_value_hold.mjs` · 묶음 짓기 = `test_272_history_empty_cells.run_helper`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_272_hidden_summary.py -q +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_272_history_empty_cells import pytestmark, run_helper # noqa: E402, F401 + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + return run_helper(tmp_path_factory) + + +def test_숨은_본문도_낡음_예약은_펼침과_같은_열쇠(out): + assert out["shown"]["error"] is None + assert out["shown"]["stale"] == [out["expectedStale"]] + assert out["hidden"]["stale"] == out["shown"]["stale"] + + +def test_숨은_본문도_유토곡선_입력은_펼침과_같음(out): + hidden, shown = out["hidden"], out["shown"] + assert hidden["massCount"] == shown["massCount"] == 1 + assert hidden["mass"] == shown["mass"] + assert hidden["mass"]["staleChainages"] == [60] + + +def test_숨은_본문은_그리기만_건너뜀(out): + assert out["hidden"]["painted"] == 0 and out["hidden"]["sized"] == 0 + assert out["shown"]["painted"] == 1 + + +def test_유토_판_접힘_펼침_모두_총괄값과_배분_선반입(out): + final = out["finalM3"] + assert final is not None + for state in ("closed", "open"): + panel = out["panel"][state] + assert panel["error"] is None, panel["error"] + assert panel["summary"] == [final], state + assert panel["scheduled"] == 1, state + + +def test_배지는_소수_한_자리(out): + assert out["badge"] == ["-17,570.0㎥", "-17,570.7㎥", "1,234.6㎥", "0.0㎥"] diff --git a/resources/tester/test_272_history_empty_cells.py b/resources/tester/test_272_history_empty_cells.py new file mode 100644 index 000000000..bf8b843eb --- /dev/null +++ b/resources/tester/test_272_history_empty_cells.py @@ -0,0 +1,104 @@ +"""272 · 468 (C1) 되돌리기 빈 칸 — 「{}」 · 「null」 · 「""」 = 칸 없음 · 세션 맵 빈 쓰기 = 칸 지움. + +R4 — 교점 ↓ → ↶ → B06(누름 뒤 첫 마운트가 빈 맵 여섯 「{}」를 씀) → ↶ 켜짐 · ↷ 사라짐 → 이제 ↶ 꺼짐 · ↷ 남음. +「[]」 는 목록 초안(관 · 구조물 모두 지움 = 저장분과 다름)이라 걸음 그대로. +짝: `helper_272_value_hold.mjs`(vite ssr 로 묶은 진짜 모듈 · `helper_272_entry.ts`). + +./venv/Scripts/python.exe -m pytest resources/tester/test_272_history_empty_cells.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def run_helper(tmp_path_factory: pytest.TempPathFactory) -> dict: + work = tmp_path_factory.mktemp("b272") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_272_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_272_entry.js", bundle) + proc = subprocess.run( + ["node", str(HERE / "helper_272_value_hold.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + return run_helper(tmp_path_factory) + + +def test_되돌린_뒤_빈_칸이_생겨도_다시하기_남음(out): + h1 = out["h1"] + assert h1["edited"] is True + assert h1["undone"] == {"undo": False, "redo": True, "grade": None} + # 빈 맵 여섯 「{}」 · 「null」 · 「""」 = 걸음 아님 + assert h1["after"] == {"undo": False, "redo": True} + + +def test_사용자_조작은_한_걸음_그대로(out): + h1 = out["h1"] + assert h1["redone"] is True + assert h1["redoGrade"] == json.dumps({"station_offsets": {"100": 1}}, separators=(",", ":")) + assert h1["final"] == {"undo": True, "redo": False} + + +def test_빈_목록_초안은_걸음(out): + # 관 모두 지움(「[]」) ≠ 칸 없음(저장분 관) — 되돌리기 · 다시하기 그대로 + assert out["h2"] == {"step": True, "undonePipes": None, "redonePipes": "[]"} + + +def test_빈_칸_걸음으로_되돌리면_빈_칸(out): + h3 = out["h3"] + assert h3["step"] is True + assert h3["crossw"] in (None, "{}") # 빈 칸 = 칸 없음(읽는 쪽은 빈 맵) + assert h3["revetx"] == json.dumps({"100.00:inlet": {"x": 1}}, separators=(",", ":")) + + +def test_세션_맵_빈_쓰기는_칸_지움(out): + h4 = out["h4"] + assert h4["emptyWrite"] is None + assert h4["filledWrite"] == '{"100.00":6}' + assert h4["clearedWrite"] is None + # 빈 맵 이동은 칸을 안 만듦 · 값 있는 맵은 새 자리로 옮겨 씀 + assert h4["moved"] == [None] * 6 + assert h4["movedFilled"] == '{"120.00":2}'