From 8b3560c4ce3059dadeb1acf174aaef748b76a8ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:21:40 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(755-3):=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=9B=90=EB=B3=B8=20=EA=BC=AD=EC=A7=93?= =?UTF-8?q?=EC=A0=90=EC=9D=84=20=EC=B2=B4=EC=9D=B8=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=ED=8C=8C=EC=9D=BC=20=EC=96=91=EB=81=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9E=90=EB=A6=84=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=ED=91=9C=EB=A9=B4=20=EB=84=93=EC=96=B4=EC=A7=84=20=EB=92=A4=20?= =?UTF-8?q?280=20m=20=EA=B8=B4=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /route/plan/source = 지표면 트림 뒤 expected_route.csv 시점 · 종점 투영 자리에서 자름(파일 없음 = 그대로) · 0.1 mm 반올림 겹친 점 하나만 - 검증_sub2 source 1,377.4 → 1,097.4 m(= csv) · follow 1,356.5 → 1,080.2 m · 검증_sub1 은 전후 같음(1,097.4) - 시험 test_635_2 양끝 자름 · 파일 없음 그대로 한 건 더함 Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_Router_PlanSource.py | 28 ++++++++++++++++++-- resources/tester/test_635_2_plan_context.py | 25 +++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_PlanSource.py b/B05_Profile/B05_Profile_Router_PlanSource.py index e4fdcebb2..7ce38b51b 100644 --- a/B05_Profile/B05_Profile_Router_PlanSource.py +++ b/B05_Profile/B05_Profile_Router_PlanSource.py @@ -2,7 +2,9 @@ 계산 자리 ③ 서버 단독 · 읽기만(파일 안 만듦). 원본 = 올린(받은) 노선 파일 꼭짓점을 사업지 좌표로 · 사용 범위(`projects.route_start_m · route_end_m`) · 지표면 트림까지 — 체인 예정노선 -(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 고친 꼭짓점은 `POST /route/follow` 가 +(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 끝 = 체인이 자른 `expected_route.csv` +양끝(755-3 · 지표면이 그 뒤 넓어져도 화면 예상노선 · 저장 csv 와 같은 길이). +고친 꼭짓점은 `POST /route/follow` 가 같은 간격(`spacing_m`)으로 조밀화한 뒤 추적 → 안 고친 원본 = 체인 초기선과 거리 0(`test_635_2`). GET /api/projects/{id}/route/plan/source @@ -13,15 +15,23 @@ from __future__ import annotations import asyncio +import math from pathlib import Path from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from shapely.geometry import LineString, Point from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths -from common_util.common_util_route_geometry import load_design_route, trim_route_to_surface +from common_util.common_util_route_geometry import ( + clip_route_by_chainage, + expected_route_csv_path, + load_design_route, + read_planned_route_csv, + trim_route_to_surface, +) from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler from config.config_db import get_db_pool @@ -57,9 +67,23 @@ def source_vertices( sampler = None if sampler is not None: points = trim_route_to_surface(points, sampler) + points = clip_to_expected(root, points) return [[float(x), float(y)] for x, y in points] if len(points) >= 2 else [] +def clip_to_expected(root: Path, points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """체인이 자른 예정노선 파일 양끝에서 자름(755-3) — 파일 없음 · 2점 미만 = 그대로.""" + path = expected_route_csv_path(root) + stored = read_planned_route_csv(path) if path.is_file() else None + if stored is None or len(stored.vertices) < 2 or len(points) < 2: + return points + line = LineString(points) + ends = [line.project(Point(v.x, v.y)) for v in (stored.vertices[0], stored.vertices[-1])] + clipped = clip_route_by_chainage(points, min(ends), max(ends)) + # 파일 좌표 반올림(0.1 mm) = 끝이 꼭짓점 바로 앞에 떨어짐 — 1 mm 안 겹친 점은 하나만 + return [p for i, p in enumerate(clipped) if not i or math.dist(p, clipped[i - 1]) > 1e-3] + + @router.get("/{project_id}/route/plan/source", response_model=None) async def read_plan_source(project_id: UUID) -> dict[str, Any] | JSONResponse: paths = await _project_paths(project_id) diff --git a/resources/tester/test_635_2_plan_context.py b/resources/tester/test_635_2_plan_context.py index dde01b129..048e79f55 100644 --- a/resources/tester/test_635_2_plan_context.py +++ b/resources/tester/test_635_2_plan_context.py @@ -133,6 +133,31 @@ def test_plan_source_skips_densify_keeps_trim(tmp_path: Path, monkeypatch) -> No assert asyncio.run(source.read_plan_source(uuid4())).status_code == 404 +def test_plan_source_clips_to_expected_csv_ends(tmp_path: Path, monkeypatch) -> None: + """755-3 — 지표면이 넓어져 트림이 덜 잘라도 체인 예정노선 파일 양끝에서 자름(같은 길이).""" + monkeypatch.setattr( + source, + "load_design_route", + lambda *a: SimpleNamespace(vertices=[SimpleNamespace(x=x, y=y) for x, y in SPARSE]), + ) + monkeypatch.setattr(source, "build_surface_sampler", lambda *a: object()) + monkeypatch.setattr(source, "trim_route_to_surface", lambda points, sampler: points) + surface = {"source_filter": "classification", "method": "dtm", "smooth": True} + dense = densify_route(SPARSE[3:30], source.SPACING_M) + write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in dense]) + got = source.source_vertices(tmp_path, surface, None) + + def length(points): + return sum(math.dist(a, b) for a, b in zip(points, points[1:])) + + assert abs(length(got) - length(dense)) < 1e-3 # 파일 좌표 0.1 mm 반올림 + assert math.dist(got[0], SPARSE[3]) < 1e-3 and math.dist(got[-1], SPARSE[29]) < 1e-3 + assert len(got) == 27 # 끝 = 꼭짓점 바로 앞 · 겹친 점 없음 + # 파일 없음 = 그대로 + expected_route_csv_path(tmp_path).unlink() + assert len(source.source_vertices(tmp_path, surface, None)) == len(SPARSE) + + MAIN = { "id": "main", "vertices": [[200000.0, 450000.0], [200100.0, 450000.0], [200200.0, 450000.0]], From a773a9d2cdbafe15964a84f513ee015f9e8410ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:28:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(753-2):=20=EC=98=B5=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=A0=EC=A7=84=20=EC=A4=84=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=E2=80=94=20=EC=83=81=EC=84=B8=20=C2=B7=20=EC=9D=B8=EB=A0=A5?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=EA=B2=BD=EB=B9=84=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EA=B3=84=EC=82=B0=20=C2=B7=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=8B=9D(M01=20=C2=B7=20M02=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9)=20=EB=AA=A8=EB=91=90=20=EC=95=88=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 753-1 서버 줄 덧칸 `옵션꺼짐` 을 화면 넷이 읽어 그 줄을 안 그림 — 공용 상세 박스(`LogicLab_Ho` boxCard · 장비 · 하위 몫은 다 빠졌을 때만) · 텍스트 수식(`LogicLab_Formula`) · 시험 계산 표(`Logic_Calc` answerView) · 공용 계산서(`calc_sheet` · M01 인력재료경비 · M02 `sheetGroups`) - 옵션 칸 · 소계 · 금액 · 차례 · cid 그대로(옵션 칸은 빠진 줄의 쓰는 입력도 포함) — 옵션을 바꾸면 보이는 줄이 바뀜 - M02 표 줄 `CoverRow.off` · 계산서 줄 `옵션꺼짐` - 새 지킴 test_750_2 에 753-2 대조(표 줄 off → 계산서 줄 · 소계 그대로) Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/calc_sheet.ts | 11 +++- .../M01_MasterData_UI_LogicLab_Formula.ts | 3 +- .../M01_MasterData_UI_LogicLab_Ho.ts | 7 ++- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 4 ++ .../M01_MasterData_UI_Logic_Calc.ts | 3 +- .../logic/M02_Sangeun_Cover_Model.ts | 14 ++++- .../tester/test_750_2_views_one_answer.py | 52 +++++++++++++++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index 4197de7a9..b7e9217af 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -29,6 +29,8 @@ export interface SheetLine { 장비?: boolean | null; 기준단위?: string | null; 환산근거?: string; + /** (753-1) 옵션으로 빠진 줄 — 그리지 않음(소계 · 쓰는 입력 칸은 그대로) */ + 옵션꺼짐?: boolean; /** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */ 펼침?: SheetUnfold | null; } @@ -270,6 +272,10 @@ function headRows( const subtotal = (g: SheetGroup): HTMLElement => createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)); +/** 옵션으로 빠진 줄(753-1)은 안 그림 — 장비 · 하위 몫 줄은 몫이 다 빠졌을 때만 */ +const shown = (l: SheetLine | SheetMerged): boolean => + "몫" in l ? l.몫.some((x) => !x.줄.옵션꺼짐) : !l.옵션꺼짐; + /** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ export function calcSheetRows(o: SheetOptions): HTMLElement[] { const rows: HTMLElement[] = []; @@ -278,7 +284,8 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] { for (const b of bundleLines(o.묶음)) { const name = bundleName(b.묶음); rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거)); - for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); + for (const line of b.줄.filter(shown)) + rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1))); } rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐 @@ -286,7 +293,7 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] { for (const g of o.묶음) { const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))]; rows.push(...headRows(g.비목, uses, o, specs)); - rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g)); + rows.push(...g.줄.filter(shown).map((line) => lineRow(line)), subtotal(g)); } } rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 0c524a5a8..69607ffe8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -158,7 +158,8 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { onInput ? specs.find((x) => x.이름 === name) : undefined; // 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계 const boxes = text.groups.map((g) => { - const lines = g.줄.map((line, i) => { + const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림 + const lines = visible.map((line, i) => { const pair = String(line.짝 ?? `${g.비목}${i}`); const open = ctx.open?.(line); return el("div", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 3e7f83396..f4b5f4cc8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -615,9 +615,12 @@ function boxCard( const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))]; const { root, tbody } = tableShell(COLS, { density: "data" }); root.classList.add("m01lab__ho"); + // 753-2 옵션으로 빠진 줄은 안 그림(옵션 칸 · 소계는 그대로 — 옵션을 바꾸면 보이는 줄이 바뀜) + const off = (e: Entry): boolean => !!(e.line?.옵션꺼짐 || e.textLine?.옵션꺼짐); + const rows = b.줄.filter((r) => !("몫" in r ? r.몫.every(off) : off(r))); tbody.append( - ...(b.줄.length - ? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) + ...(rows.length + ? rows.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) : [emptyRow(COLS, tl("Empty_Group"))]), ); const sub = tableRow(COLS, [ diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 0176da2d4..d4cae374e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -105,6 +105,8 @@ export interface CalcLine { 까닭?: string; /** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */ 원문?: { 비목?: Record }; + /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ + 옵션꺼짐?: boolean; } /** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */ @@ -160,6 +162,8 @@ export interface TextLine { 환산근거?: string; /** (M02 750-1) SK 셈 답 줄 cid — 상세 줄과 이음 */ cid?: string; + /** (753-1) 옵션으로 빠진 줄(수량 0 이 만약 갈래 탓) — 화면은 그 줄을 안 그림(금액 · 차례 그대로) */ + 옵션꺼짐?: boolean; } /** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */ export type TextAnswer = diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index a8ae1d129..10a7bcb38 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -262,7 +262,8 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): } const parts: HTMLElement[] = [...note]; if (answer.lines) { - const rows = answer.lines.map((line) => + const visible = answer.lines.filter((line) => !line.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림 + const rows = visible.map((line) => el("tr", { children: [ el("td", { diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 1f8130c45..b6103a4f5 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -90,6 +90,8 @@ export interface SkLine { 원문?: { 수량: number | null; 금액: number | null; 비목?: Record }; /** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */ 품목?: CalcLine["품목"]; + /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ + 옵션꺼짐?: boolean; } export interface SkMat { @@ -152,6 +154,8 @@ export interface CoverRow { sub: { k: number; cid: string; scale: number } | null; /** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */ t: number; + /** (753-2) 옵션으로 빠진 줄 — 계산서에서 안 그림 */ + off: boolean; } /* ── 순수 셈 ── */ @@ -238,7 +242,13 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR 금액: number | null; }, parts: Record, - extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] }, + extra: { + 묶음?: string | null; + 율?: number; + apart?: boolean; + 바탕값?: SkLine["바탕값"]; + 옵션꺼짐?: boolean; + }, ): CoverRow => { const hit = ADD_RE.exec(cid); const added = hit ? Number(hit[1]) : null; @@ -282,6 +292,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR apartPick: one?.종류 === "재료", sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함 + off: !!extra.옵션꺼짐, }; }; for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l)); @@ -421,6 +432,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { 금액: amount, 묶음: r.묶음, 장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"), + ...(r.off ? { 옵션꺼짐: true } : {}), }); } } diff --git a/resources/tester/test_750_2_views_one_answer.py b/resources/tester/test_750_2_views_one_answer.py index bd23618aa..6f8bf6815 100644 --- a/resources/tester/test_750_2_views_one_answer.py +++ b/resources/tester/test_750_2_views_one_answer.py @@ -41,3 +41,55 @@ def test_752_1_source_panel_only_on_sangeun_screen(): assert "source" not in _ts("M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts") detail = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") assert "opt.source ? createSangeunSide()" in detail and "side?.set(" in detail + + +def test_753_2_option_off_flag_reaches_sheet_and_views(): + """753-2 옵션으로 빠진 줄 — SK 줄 `옵션꺼짐` → 표 줄 `off` → 계산서 줄 `옵션꺼짐`(공용 계산서 · 상세 · + 텍스트 수식 · 시험 계산이 안 그림) · 소계 · 금액은 그대로.""" + import json + import re + import shutil + import subprocess + + import pytest + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + m02 = ROOT / "M02_MasterTemplete" + strip = lambda p: re.sub(r"^import .*$", "", p.read_text("utf-8"), flags=re.M) # noqa: E731 + src = "\n".join( + [ + strip(m02 / "logic" / "M02_Born_Tag.ts"), + strip(m02 / "logic" / "M02_Sangeun_Undo.ts"), + strip(m02 / "logic" / "M02_Sangeun_Cover_Model.ts"), + """ +const line = (cid, 이름, 수량, 금액, 꺼짐) => ({ cid, 이름, 단위: "개", 수량, 단가: 100, 금액, + 비목: { 재료비: 금액 }, 묶음: "재료", ...(꺼짐 ? { 옵션꺼짐: true } : {}) }); +const ans = { 기준: null, 짝잃음: [], 바탕판: "x", 정체: ["material:A#0", "material:B#0"], + 줄: [line("material:A#0", "폼타이 12.7", 0, 0, true), line("material:B#0", "합판", 1, 100, false)] }; +const rows = rowsOf(ans, null, {}); +const groups = sheetGroups(rows); +console.log(JSON.stringify({ off: rows.map((r) => r.off), + sheet: groups.map((g) => [g.비목, g.소계, g.줄.map((x) => [x.이름, x.옵션꺼짐 ?? false, x.금액])]) })); +""", + ] + ) + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["off"] == [True, False] + assert got["sheet"] == [["재료비", 100, [["폼타이 12.7", True, 0], ["합판", False, 100]]]] + sheet = (ROOT / "A00_Common" / "calc_sheet.ts").read_text("utf-8") + assert "b.줄.filter(shown)" in sheet and "g.줄.filter(shown)" in sheet + ho = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text("utf-8") + assert "옵션꺼짐" in ho + assert "옵션꺼짐" in ( + ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Formula.ts" + ).read_text("utf-8") + assert "옵션꺼짐" in (ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Calc.ts").read_text( + "utf-8" + ) From 9d5778bf57c97491b4cea215fd7029b3cdec0ac9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:28:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(755-3):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EC=88=98=EC=A0=95=20=EC=BC=A0=20=EC=B1=84=20?= =?UTF-8?q?=ED=8C=90=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20[=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20=C2=B7=20[=ED=99=95=EC=A0=95]=20=EB=A7=89=EA=B3=A0?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EB=96=A0=EB=82=A8=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=ED=83=AD=20=EB=8B=AB=EA=B8=B0=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_save 저장 막음 자리(holdDesignSave · 화면에서 빠지면 풂) · 막음 = 저장 줄 · 가림막 앞 - ExpectedDraw = 켠 채 판 고침(사진첩 한 걸음 이상)이면 막음 글 · 공용 떠남 물음(guardProjectLeave) · 되돌려 고침 없으면 안 물음 - 시험 test_755 지킴 한 건 더함 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_save.ts | 18 ++++++++++++++++++ B05_Profile/B05_Profile_UI_ExpectedDraw.ts | 13 +++++++++++++ resources/tester/test_755_expected_flow.py | 14 ++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index ebf731acc..013e61792 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -52,6 +52,22 @@ import { designSignature, markDesignClean } from "./design_leave"; import { routeGradeTouch } from "./design_pipeline_page"; import type { GradeTouch } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; +/** [저장] · [확정] 막음(755-3) — 글을 돌려주면 알림하고 안 냄 · `root` 가 화면에서 빠지면 풂. */ +const saveHolds: Array<{ root: HTMLElement; hold: () => string | null }> = []; +export function holdDesignSave(root: HTMLElement, hold: () => string | null): void { + saveHolds.push({ root, hold }); +} +function heldSave(): string | null { + for (let index = saveHolds.length - 1; index >= 0; index -= 1) { + if (!saveHolds[index].root.isConnected) saveHolds.splice(index, 1); + } + for (const { hold } of saveHolds) { + const why = hold(); + if (why) return why; + } + return null; +} + export interface DesignSaveContext { projectId: string; routeId: () => number | null; @@ -249,6 +265,8 @@ export async function saveDesign( const confirm = options.confirm ?? false; const routeId = ctx.routeId(); if (routeId === null) return false; + const held = heldSave(); + if (held) return (showToast(held, "warning"), false); saving.add(ctx.projectId); let locked = confirm; if (locked) showLoadingOverlay(); diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts index 6b36b2c80..0adcb7599 100644 --- a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -11,11 +11,14 @@ * 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) → * `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`). * [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로. + * 켠 채 판을 고쳤으면(755-3) = [저장] · [확정] 막고 알림(`holdDesignSave`) · 떠남 = 공용 떠남 물음 · 탭 닫기 = 브라우저 물음. * 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`). * 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄. * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; +import { guardProjectLeave } from "../A00_Common/b_project_switch"; +import { holdDesignSave } from "../A00_Common/design/design_save"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { createButton, showToast } from "@ui/ui_template_elements"; import { askConfirm } from "@ui/ui_template_modal"; @@ -42,6 +45,8 @@ import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_Ro type Vertex = [number, number]; const OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다."; +const HELD = + "예정노선 수정 중 — 「예정노선 수정」을 끈 뒤 [저장]합니다(켠 동안 고친 판은 끌 때 평면선에 들어감)."; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; @@ -132,6 +137,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; }; const measuring = (): boolean => open && params.measuring(); + /** 켠 채 판을 고침 — [저장] · 떠남이면 잃음(755-3). */ + const pending = (): boolean => open && undoStack.length > 0; + holdDesignSave(canvas, () => (pending() ? HELD : null)); + guardProjectLeave(canvas, { + dirty: pending, + save: async () => (showToast(HELD, "warning"), false), + message: HELD, + }); // 속성 판 경위도 = 서버 변환(635-5) const coords = createPlanCoords(params.projectId, () => kit.props.render()); diff --git a/resources/tester/test_755_expected_flow.py b/resources/tester/test_755_expected_flow.py index 95f888405..26311c0a3 100644 --- a/resources/tester/test_755_expected_flow.py +++ b/resources/tester/test_755_expected_flow.py @@ -3,6 +3,7 @@ - 755-1 켠 동안 = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 이 판 사진첩(Ctrl+Z · 도구줄 단추) · 끌 때 한 번 = 끊겼으면 알림하고 안 끔 · 바뀌었으면 `/route/follow` 한 걸음 · [잇기] = B05 사용. - 755-2 켠 동안 재기 = D03 재기 겹 · 받은 노선 = 분홍 점선 + 검은 테 · 켬 설명 걷음 · 위치 칸 걷음. +- 755-3 켠 채 판 고침 = [저장] · [확정] 막고 알림 · 떠남 = 공용 떠남 물음(탭 닫기 = 브라우저 물음). ./venv/Scripts/python.exe -m pytest resources/tester/test_755_expected_flow.py -q """ @@ -82,3 +83,16 @@ def test_받은_노선_색_설명_위치_걷음(): assert re.search(r'key: "site",[^}]*d03: "use"', table, re.S) # D03 그대로 theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") assert "--draw-map-route-3:" in theme + + +def test_켠_채_저장_떠남_알림(): + body = _body().replace("\r\n", "\n") + assert "const pending = (): boolean => open && undoStack.length > 0;" in body + assert "holdDesignSave(canvas, () => (pending() ? HELD : null));" in body + assert "guardProjectLeave(canvas, {\n dirty: pending," in body + save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8") + save = save.replace("\r\n", "\n") + head = save[save.index("export async function saveDesign(") :] + held = head.index("const held = heldSave();") + assert held < head.index("saving.add(ctx.projectId);") # 막음 = 저장 줄 앞 · 가림막 없음 + assert held < head.index("showLoadingOverlay")