fix(468 값 안 변함 ③): 되돌리기 빈 칸 · 접힘 파생값 · 배지 소수 한 자리

- 사진첩: 「{}」 · 「null」 · 「""」 = 칸 없음 — 빈 맵 쓰기가 걸음이 되어 ↷ 를 버리던 흠(R4) · 「[]」(관 · 구조물 모두 지움)은 값으로 둠
- 세션 맵 · 연동 플래그: 빈 맵 persist = 칸 지움(갈아끼움 이동이 「{}」 여섯 칸을 만들지 않음)
- 종단도: 본문 0 크기(판 접힘)에도 낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료를 셈 · 그리기만 건너뜀 — 떼어 낸 유토 판은 그림
- 배지: 소수 한 자리 고정(−17,570.0㎥)
- 시험: test_272_history_empty_cells · test_272_hidden_summary(옛 코드 7 실패)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 07:21:14 +09:00
co-authored by Claude Opus 5.5
parent 039b5a3f3b
commit 0ffabd6fbf
9 changed files with 643 additions and 65 deletions
+10 -2
View File
@@ -45,11 +45,19 @@ const isEditKey = (key: string, projectId: string): boolean =>
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
export type HistorySnapshot = Record<string, string>;
/** 빈 칸 = 칸 없음(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;
@@ -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;
}
+96 -54
View File
@@ -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<typeof deriveProfile>;
/** 유토곡선에 넘길 값 — 그림과 숨은 셈이 한 벌(가로 배치만 다름). */
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));
}
}
@@ -35,7 +35,7 @@ export interface SessionMap<V> {
values: Map<string, V>;
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
load: () => void;
/** 맵을 통째로 세션에 쓴다. */
/** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */
persist: () => void;
}
@@ -98,7 +98,8 @@ export function createSessionMap<V>(
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)
);
},
};
+2 -2
View File
@@ -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 {
+11
View File
@@ -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";
+353
View File
@@ -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);
@@ -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㎥"]
@@ -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}'