From 78188cf7a351f235b7d244aa4f3f4576d17adbf8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 07:14:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=A1=B0=EC=9E=91=20?= =?UTF-8?q?=EB=86=93=EC=9D=80=20=EC=A7=81=ED=9B=84=20=E2=86=B6=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EC=BC=9C=EC=A7=90=20=C2=B7=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=BC=20=EC=A4=91=20=E2=86=B6=20=3D=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=EC=B0=8D=EA=B3=A0=20=EB=90=98=EB=8F=8C=EB=A6=BC=20(PLAN=20477-?= =?UTF-8?q?3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찍기 기다림(400 ms) 들어가면 한 틱 뒤 단추 맞춤 — 세션이 지금 걸음과 다를 때만 켜짐(바뀐 것 없는 누름은 그대로 꺼짐) - ↶ ↷ 누르면 기다리던 찍기 먼저(누름 중이어도) — 방금 조작이 한 걸음으로 되돌아감 - 기다림 시간 · 서버 다시 셈 묶음은 그대로 Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_UI_Profile_History.ts | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 37fe67636..c7b91d28a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -114,10 +114,18 @@ const changed = (): void => hooks.forEach((entry) => entry.change?.()); function settle(): void { clearTimeout(settleTimer); settling = true; - settleTimer = setTimeout(() => { - settling = false; - active?.record(); - }, SETTLE_MS); + settleTimer = setTimeout(flushSettle, SETTLE_MS); + // ↶ 는 기다리지 않고 켬(477-3) — 같은 손길의 칸 쓰기가 끝난 한 틱 뒤 단추만 맞춤 + setTimeout(changed, 0); +} + +/** 기다리던 찍기를 지금 — 기다림이 끝났거나 그 사이 ↶ ↷ 를 누름(477-3 · 한 걸음 그대로). */ +function flushSettle(): void { + if (!settling) return; + clearTimeout(settleTimer); + settling = false; + active?.recordNow(); + changed(); } function watchPointer(): void { @@ -167,6 +175,8 @@ function restoreSnapshot(projectId: string, snapshot: HistorySnapshot): void { export interface ProfileHistory { /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ record(): void; + /** 누름이 남아 있어도 지금 찍음 — ↶ 단추 누름 중 기다리던 찍기(477-3). */ + recordNow(): void; /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ pause(): void; /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ @@ -224,6 +234,10 @@ export function createProfileHistory( return true; } + /** 찍기를 기다리는 동안 세션이 지금 걸음과 다름 — ↶ 를 바로 켜는 몫(477-3). */ + const pending = (): boolean => + settling && !restoring && !sameSnapshot(takeSnapshot(projectId, stack[cursor]), stack[cursor]); + const history: ProfileHistory = { record() { if (restoring || paused) return; @@ -231,6 +245,10 @@ export function createProfileHistory( releaseWaiters.add(history.record); return; } + history.recordNow(); + }, + recordNow() { + if (restoring || paused) return; sync(); restAt = gestures; const snapshot = takeSnapshot(projectId, stack[cursor]); @@ -253,10 +271,11 @@ export function createProfileHistory( paused = false; history.record(); }, - undo: () => apply(cursor - 1), - redo: () => apply(cursor + 1), - canUndo: () => cursor > 0, - canRedo: () => cursor < stack.length - 1, + // 기다리던 찍기 먼저 — 바로 누른 ↶ = 방금 조작 되돌림 · 묶음 한 걸음(477-3) + undo: () => (flushSettle(), apply(cursor - 1)), + redo: () => (flushSettle(), apply(cursor + 1)), + canUndo: () => cursor > 0 || pending(), + canRedo: () => cursor < stack.length - 1 && !pending(), }; rebaseActive = () => { if (restoring || paused || pointerHeld || settling || gestures !== restAt) return; From ffa762438fa1b151d790b5cef709b1346d8e2636 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 07:14:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=86=8C=EB=8B=A8=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20berm=20=3D=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=86=B5=20=E2=80=94=20=EC=B4=88=EC=95=88=20=EC=B9=B8=20=C2=B7?= =?UTF-8?q?=20=E2=86=B6=20=EC=82=AC=EC=A7=84=20=C2=B7=20=EB=96=A0=EB=82=A8?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8=20=EC=A4=84=EC=97=90=EC=84=9C=20=EB=B9=A0?= =?UTF-8?q?=EC=A7=90=20(PLAN=20477-4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - berm 은 구조물(소단)에서 셈한 값 · 손 고침 길 없음(쓰는 곳 = 페이지 시설 줄 하나) → 등록부 결과 통으로 - 떠남 확인 제외 목록의 berm 줄 지움(초안 통이 아니라 필요 없음) - 쓰는 곳 없는 readBermSpans 지움 · 읽는 곳(시설 줄 · 다시 셈) 이름 그대로 - 지킴 시험 test_477_undo_now_berm(즉시 켜짐 · flush · 묶음 · berm 통) Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 7 +- A00_Common/design/design_leave.ts | 1 - B06_Section/B06_Section_UI_Page_Persist.ts | 20 +-- resources/tester/test_477_undo_now_berm.py | 142 +++++++++++++++++++++ 4 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 resources/tester/test_477_undo_now_berm.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 810c7b6f5..38c3a5045 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -145,10 +145,6 @@ export const STATE_REGISTRY = { * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ cutslope: { bucket: "draft", scope: "route" }, - /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. - * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 - * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ - berm: { bucket: "draft", scope: "route" }, /** 표준 횡단면 설정 패널의 편집값. * * 칸이 비었으면 `sections/context` 의 저장분(`stored_standard_cross_section`)으로 채운다 @@ -239,6 +235,9 @@ export const STATE_REGISTRY = { "design-stations": { bucket: "result", scope: "project" }, "design-profile": { bucket: "result", scope: "project" }, "design-footprint": { bucket: "result", scope: "project" }, + /** 소단 사본 — 구조물(소단)에서 셈한 구간 목록(`design_facility_lane` 만 씀 · 고침은 `structures` 칸). + * 다시 셈에 실어 보냄 · 안 실으면 계획선을 고치는 순간 계단이 사라짐(3-9 · 477-4). */ + berm: { bucket: "result", scope: "route" }, } as const satisfies Record; export type StateName = keyof typeof STATE_REGISTRY; diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 49126e933..9e3cc9ecb 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -31,7 +31,6 @@ const NOT_EDITS = new Set([ "structure-pick", "structure-type-cache", "std-cross", - "berm", // 소단 사본 — 열 때 시설 줄이 구조물에서 씀(272 · 고침은 `structures` 칸) ]); const OTHER_PAGE = /^(b0[89]|d0\d|e0\d)-/; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index f14377a40..5f82ba91b 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -16,13 +16,7 @@ import { } from "./B06_Section_Api_Fetch"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./logic/B06_Section_UI_Page_Patches"; -import { - clearState, - readByKey, - readState, - writeByKey, - writeState, -} from "../A00_Common/b_page_state"; +import { clearState, readByKey, writeByKey, writeState } from "../A00_Common/b_page_state"; import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options"; import { crossDesignChoices, @@ -130,17 +124,7 @@ function putCell( } } -/** - * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. - * - * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 - * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). - */ -export function readBermSpans(projectId: string, routeId: number): BermSpan[] { - const stored = readState("berm", projectId, routeId); - return Array.isArray(stored) ? stored : []; -} - +/** 소단 사본(결과 통 · 구조물에서 셈 · 477-4) — 쓰는 곳은 페이지 시설 줄 하나. */ export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void { writeState("berm", spans, projectId, routeId); } diff --git a/resources/tester/test_477_undo_now_berm.py b/resources/tester/test_477_undo_now_berm.py new file mode 100644 index 000000000..b37561579 --- /dev/null +++ b/resources/tester/test_477_undo_now_berm.py @@ -0,0 +1,142 @@ +"""477-3 · 477-4 (요구 433) — ↶ 바로 켜짐 · 소단 사본 = 결과 통. + +못박는 것 + - 477-3 조작 놓은 직후(찍기 기다림 400 ms 안) ↶ 켜짐 · 단추 맞춤 알림 · 바뀐 것 없는 누름은 안 켜짐. + - 기다림 중 ↶ = 먼저 찍고 되돌림(한 번에 조작 전) · ↷ = 다시 · 서버 다시 셈 뒤 쓰기 묶음은 그대로 한 걸음. + - 477-4 `berm` = 결과 통(사진 · 떠남 물음 밖) · 떠남 제외 목록에 사본 줄 없음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_477_undo_now_berm.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts" +LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs" + +DRIVER = r""" +import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +const data = new Map(); +globalThis.sessionStorage = { + get length() { return data.size; }, + key: (i) => [...data.keys()][i] ?? null, + getItem: (k) => (data.has(k) ? data.get(k) : null), + setItem: (k, v) => data.set(k, String(v)), + removeItem: (k) => data.delete(k), +}; +globalThis.window = new EventTarget(); +window.setTimeout = setTimeout; +window.sessionStorage = sessionStorage; +window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; +const { loadTs } = createRequire(import.meta.url)(process.argv[3]); +const { createProfileHistory, onPageHistory } = loadTs(process.argv[4]); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const PIPES = "aislo:draft:pipes:p1"; +const CROSS = "aislo:draft:crossdesign:p1:7"; +const BERM = "aislo:result:berm:p1:7"; +const out = {}; +let changes = 0; +onPageHistory({ change: () => (changes += 1) }); +const press = () => window.dispatchEvent(new Event("pointerdown")); +const lift = () => window.dispatchEvent(new Event("pointerup")); + +// 1. 관 지움 — 놓은 한 틱 뒤 ↶ 켜짐(기다림 400 ms 안) +sessionStorage.setItem(PIPES, '[{"c":10},{"c":20}]'); +sessionStorage.setItem(BERM, "[]"); +const h = createProfileHistory(() => {}); +out.openCanUndo = h.canUndo(); +press(); +sessionStorage.setItem(PIPES, '[{"c":10}]'); +lift(); +await wait(30); +out.soonCanUndo = h.canUndo(); +out.soonChanges = changes; +// 바로 ↶ 단추(누름 · 놓음 · 누름 처리) — 기다리던 찍기 먼저 · 한 번에 조작 전 +press(); +lift(); +out.undo = h.undo(); +out.afterUndo = sessionStorage.getItem(PIPES); +out.undoLeft = h.canUndo(); +out.canRedo = h.canRedo(); +await wait(450); +out.redo = h.redo(); +out.afterRedo = sessionStorage.getItem(PIPES); +out.redoLeft = h.canRedo(); + +// 2. 서버 다시 셈 뒤 쓰기(200 ms) — 기다림이 끝나면 조작과 한 걸음 +data.clear(); +sessionStorage.setItem(PIPES, "a"); +sessionStorage.setItem(CROSS, "x"); +const h2 = createProfileHistory(() => {}); +press(); +sessionStorage.setItem(PIPES, "b"); +lift(); +await wait(200); +sessionStorage.setItem(CROSS, "y"); +sessionStorage.setItem(BERM, '[{"s":1}]'); // 사본 = 결과 통 — 사진 밖 +await wait(300); +h2.undo(); +out.grouped = [sessionStorage.getItem(PIPES), sessionStorage.getItem(CROSS)]; +out.groupedLeft = h2.canUndo(); +out.bermKept = sessionStorage.getItem(BERM); + +// 3. 바뀐 것 없는 누름 — ↶ 안 켜짐(깜빡임 없음) +data.clear(); +sessionStorage.setItem(PIPES, "a"); +const h3 = createProfileHistory(() => {}); +press(); +lift(); +await wait(30); +out.idleCanUndo = h3.canUndo(); +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def result(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_477") + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_놓은_직후_되돌리기_켜짐(result) -> None: + assert result["openCanUndo"] is False + assert result["soonCanUndo"] is True + assert result["soonChanges"] >= 1 # 단추 맞춤 알림이 기다림 전에 감 + + +def test_기다림_중_되돌리기_먼저_찍고_한_걸음(result) -> None: + assert result["undo"] is True + assert result["afterUndo"] == '[{"c":10},{"c":20}]' + assert result["undoLeft"] is False and result["canRedo"] is True + assert result["redo"] is True and result["afterRedo"] == '[{"c":10}]' + assert result["redoLeft"] is False + + +def test_서버_다시_셈_묶음_그대로(result) -> None: + assert result["grouped"] == ["a", "x"] and result["groupedLeft"] is False + assert result["bermKept"] == '[{"s":1}]' # 결과 통 사본은 되돌리기가 안 건드림 + + +def test_바뀐_것_없는_누름은_안_켜짐(result) -> None: + assert result["idleCanUndo"] is False + + +def test_소단_사본은_결과_통() -> None: + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert re.search(r'^\s*berm: \{ bucket: "result", scope: "route" \}', state, re.M) + leave = (ROOT / "A00_Common" / "design" / "design_leave.ts").read_text(encoding="utf-8") + assert '"berm"' not in leave