From eedafe8db99f88e22731cf22a0786517710904ba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:37:07 +0900 Subject: [PATCH] =?UTF-8?q?fix(451=20B06=20=EC=84=B8=EC=85=98=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=ED=8C=90):=20=EC=84=B8=EC=85=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=92=A4=EC=97=90=EC=84=9C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=8C=90=20=ED=99=95=EC=9D=B8=20=C2=B7=20=EB=8B=A4=EB=A5=B4?= =?UTF-8?q?=EB=A9=B4=20=EC=84=9C=EB=B2=84=20=EA=B0=92=20=EC=9C=84=20?= =?UTF-8?q?=EB=82=B4=20=EC=B9=B8=EB=A7=8C=20=C2=B7=20=EC=88=A8=EC=9D=80=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EA=B4=80=20id=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=C2=B7=20384=5F3=20=EB=8B=A8=EC=96=B8=2042?= =?UTF-8?q?7=20=EC=9D=98=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-16: 새로고침 · 새 탭 = 세션 상세로 먼저 섬 · 뒤에서 상세 GET(ETag 304) · 판이 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 새 판 · 노선 초안 hold 는 버릴 때 새 판 - 440-3: 숨은 종단 보기도 관 세션 id 장부 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때 - 384_3: pvi 단언 = 저장 꼭짓점 + 심은 측점마다 세운 꼭짓점(427) - 384_7: 세션 쓰기 = 받을 때 · 판 확인 새 판 둘 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_view_profile.ts | 4 +- B06_Section/B06_Section_Section_Store.ts | 62 ++++- resources/tester/helper_384_3_planted.mjs | 1 + resources/tester/helper_451_session_canon.cjs | 225 ++++++++++++++++++ resources/tester/test_384_3_planted_base.py | 5 +- resources/tester/test_384_7_section_canon.py | 3 +- resources/tester/test_451_session_canon.py | 71 ++++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_451_session_canon.cjs create mode 100644 resources/tester/test_451_session_canon.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 902e38bb1..db9d53e9c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -159,7 +159,8 @@ export async function mountProfileView( const followed = designStore.get("pipes"); if (followed) profilePanel.drainage.followPipes(followed); else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload(); - if (!host.visible()) return; + // 숨어도 관 세션 id 장부는 따라감 — 평면 마커 누름이 초안 자리를 고름(440-3) · 그리기는 보일 때 + if (!host.visible()) return void (lastPipes && shownPipes(lastPipes)); if (lastPipes) applyPipes(lastPipes, false); bridge.takeStructures(designStore.get("structures")); }; @@ -214,6 +215,7 @@ export async function mountProfileView( onPipesChanged: (pipes) => { lastPipes = pipes; if (host.visible()) applyPipes(pipes); + else shownPipes(pipes); // 숨어도 관 id 장부만(440-3) }, onStructureMove: (from, to, station) => { if (isPipeStation(station)) profilePanel.drainage.movePipe(from, to); diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index ac87151c4..dd16e9ea0 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -17,7 +17,7 @@ * 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다. * ========================================================================== */ -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { clearState, readState, readStateRaw, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station"; import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations"; @@ -30,6 +30,7 @@ import { import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts"; import type { + CrossSection, CrossSectionPatch, SectionDetailResponse, StandardCrossSection, @@ -126,6 +127,7 @@ export async function loadSectionDetail( const stored = sessionDetail(projectId, routeId); // 세션 글 = 한 번만 풂(387-7) if (stored) { cache.set(key, stored); + void checkSessionCanon(projectId, routeId, stored).catch(() => undefined); return withDraftWalls(stored, projectId, routeId); } } @@ -143,6 +145,62 @@ export async function loadSectionDetail( return request; } +/** + * 세션 상세 판 확인(433-16) — 새로고침 · 새 탭 첫 화면은 세션 한 벌로 서고, 뒤에서 서버에 한 번 물음(ETag · + * 같으면 304). 다른 탭 · 다른 PC 가 저장해 판이 다르면 서버 값 위에 **이 탭에서 고친 사용자 칸만**(세션 정본 + * 사본과 다른 칸) 얹음 — 낡은 캐시가 [저장] patch 바탕(445)으로 서버 값을 되쓰지 않게. 새 상세로 갈아끼워 듣는 + * 쪽이 다시 그림 · 노선 초안이 옛 상세를 쥐고 있으면(hold) 버릴 때 새 판으로(`renewed`). + */ +async function checkSessionCanon( + projectId: string, + routeId: number, + stored: SectionDetailResponse, +): Promise { + const fresh = await fetchSectionDetail(projectId, routeId); + if (!Array.isArray(fresh?.cross_sections)) return; + const raw = readStateRaw("section-detail", projectId, routeId); + if (raw === null || raw === JSON.stringify(fresh)) return; // 같은 판 · 그 사이 버림 + const key = keyOf(projectId, routeId); + const current = cache.get(key); + const edits = userEdits(current ?? stored, savedDesigns(projectId, routeId) ?? []); + writeState("section-detail", fresh, projectId, routeId); + keepCanon(projectId, routeId, fresh); + for (const section of fresh.cross_sections) { + const edit = edits.find((entry) => Math.abs(entry.chainage_m - section.chainage_m) < 0.01); + if (edit && section.design) section.design = { ...section.design, ...edit.cells }; + } + renewed.set(stored, fresh); + if (current !== stored) return; // 노선 초안 갈아끼움 중 · 그 사이 새로 받음 — 초안을 버리면 새 판 + cache.set(key, fresh); + await withDraftWalls(fresh, projectId, routeId); + if (cache.get(key) !== fresh) return; + emitSwap(fresh, new Map()); + listeners.get(key)?.forEach((fn) => fn(fresh, new Map())); +} + +/** 판이 바뀐 세션 상세 → 새 판 상세(433-16) — 노선 초안 hold 가 쥔 옛 객체를 되돌릴 때. */ +const renewed = new WeakMap(); + +/** 측점마다 정본 사본과 다른 사용자 칸(`USER_TOUCHED_KEYS`) — 이 탭에서 고친 것. */ +function userEdits( + detail: SectionDetailResponse, + canonRows: ReadonlyArray<{ chainage_m: number; design: CrossSection["design"] }>, +): Array<{ chainage_m: number; cells: Record }> { + const out: Array<{ chainage_m: number; cells: Record }> = []; + for (const section of detail.cross_sections) { + if (!section.design) continue; + const now = section.design as unknown as Record; + const row = canonRows.find((r) => Math.abs(r.chainage_m - section.chainage_m) < 0.01); + const was = (row?.design ?? {}) as unknown as Record; + const cells: Record = {}; + for (const name of USER_TOUCHED_KEYS) + if (now[name] !== undefined && JSON.stringify(now[name]) !== JSON.stringify(was[name])) + cells[name] = now[name]; + if (Object.keys(cells).length) out.push({ chainage_m: section.chainage_m, cells }); + } + return out; +} + const listeners = new Map>(); /** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */ @@ -267,7 +325,7 @@ export async function restoreSectionDetail( const key = keyOf(projectId, routeId); const current = cache.get(key); if (!current) return null; - const saved = hold.detail; + const saved = renewed.get(hold.detail) ?? hold.detail; // 그 사이 서버 판이 바뀌었으면 새 판(433-16) const sections = saved.cross_sections; const same = sections.length === stations.length && diff --git a/resources/tester/helper_384_3_planted.mjs b/resources/tester/helper_384_3_planted.mjs index 4b1bc6b95..7e1d546be 100644 --- a/resources/tester/helper_384_3_planted.mjs +++ b/resources/tester/helper_384_3_planted.mjs @@ -119,6 +119,7 @@ process.stdout.write( both: ids(both), noDetail: noDetail === viewBase, pvi: both.basePvi.length, + pviAt: both.basePvi.map((node) => node.chainage_m), kinds: [on.type_id, off.type_id], }), ); diff --git a/resources/tester/helper_451_session_canon.cjs b/resources/tester/helper_451_session_canon.cjs new file mode 100644 index 000000000..80e16b9e2 --- /dev/null +++ b/resources/tester/helper_451_session_canon.cjs @@ -0,0 +1,225 @@ +/* 451 헬퍼(433-16) — 세션 상세 판 확인: 진짜 상세 가게(`B06_Section_Section_Store`)가 세션 한 벌로 먼저 서고 + * 뒤에서 서버 판을 물음 · 다르면 서버 값 위에 이 탭에서 고친 사용자 칸만 · 같으면 그대로 · 노선 초안 hold 는 버릴 때 + * 새 판. 서버 창구 · 초안 얹기 · 열쇠는 가짜. 짝 = `test_451_session_canon.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const P = "P451"; +const ROUTE = 3; + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + key: (i) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() }; + +const anyModule = (extra = {}) => + new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) }); + +/** 서버 — 부를 때마다 지금 `server` 사본 · `gate` 가 풀릴 때 답함. */ +let server = null; +let gate = Promise.resolve(); +let asked = 0; +const keyAt = (sections) => sections.map((s) => `at:${s.chainage_m.toFixed(2)}`); +const STUBS = { + "design_pipeline_page.ts": { + reconcilePagePipeline: async () => 0, + pipelineOwnsCross: () => false, + pagePipeline: () => null, + }, + "design_pipeline_cross.ts": { forceCross: () => undefined }, + "B06_Section_Section_Drafts.ts": { + forgetDraftStations: () => undefined, + withDraftShapes: async () => undefined, + withDraftStations: async () => undefined, + supersedeDraftStations: () => undefined, + }, + "B05_Profile_Api_PlusStations.ts": { + loadSavedPlusStations: async () => undefined, + plusStationsOf: () => [], + }, + "B06_Section_Api_Fetch.ts": anyModule({ + fetchSectionDetail: async () => { + asked += 1; + await gate; + return JSON.parse(JSON.stringify(server)); + }, + }), + "B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }), + "B06_Section_Swap_Bus.ts": anyModule(), + "B06_Section_Station_Keys.ts": anyModule({ + KEYED_STATES: [], + registeredKeys: (_p, _r, sections) => keyAt(sections), + stationIntervalOf: () => 20, + }), + "B06_Section_UI_Page_Persist.ts": anyModule({ readCutSlopeSession: () => ({}) }), + "B06_Section_UI_Standard_Panel.ts": anyModule(), + "B06_Section_Cross_Design_Session.ts": anyModule({ crossDesignChoices: () => new Map() }), + "B06_Section_Section_Adjusts.ts": anyModule({ withPipeOptions: async () => undefined }), + "common_util_cross_design.ts": anyModule(), + "B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(), + "B06_Section_UI_Cross_Ford_Geom.ts": anyModule(), + "B06_Section_UI_Cross_Box_Geom.ts": anyModule(), + "B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }), + "config_frontend.ts": anyModule({ PIPE_TYPES: [] }), +}; + +const cache = new Map(); +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(full) { + const stub = STUBS[path.basename(full)]; + if (stub) return stub; + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const near = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts")); +const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); +const tick = () => new Promise((r) => setTimeout(r, 10)); + +/** 정본 — 측점 0 · 20 · 40 · 60 · 측점 20 집수정 조절 · 40 메모 · 계획고. */ +const canon = (adjust, note, elevation) => ({ + longitudinal: { design_profiles: [], stations: [] }, + cross_sections: [0, 20, 40, 60].map((c) => ({ + chainage_m: c, + kind: c === 0 ? "bp" : "regular", + samples: [], + design: { + design_elevation_m: elevation, + ...(c === 20 ? { basin_adjust: { x: adjust } } : {}), + ...(c === 40 ? { structure_note: note } : {}), + }, + })), +}); +const designAt = (detail, c) => detail.cross_sections.find((s) => s.chainage_m === c).design; +const patchAt = (patches, c) => patches.find((p) => p.chainage_m === c) ?? null; +const sessionNow = () => state.readState("section-detail", P, ROUTE); +const open = () => { + let release; + gate = new Promise((r) => (release = r)); + return release; +}; + +(async () => { + const out = {}; + // ① 낡은 세션(조절 1 · 메모 A · 계획고 100) + 다른 탭이 저장(조절 2 · 계획고 101) → 새 탭(새로고침) + state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); + server = canon(2, "A", 101); + let release = open(); + let heard = 0; + store.subscribeSectionDetail(P, ROUTE, () => (heard += 1)); + const first = await store.loadSectionDetail(P, ROUTE); + out.firstFromSession = designAt(first, 20).basin_adjust.x === 1 && asked === 1; + // 서버가 답하기 전 이 탭에서 고친 칸 — 측점 40 메모 + designAt(first, 40).structure_note = "mine"; + release(); + await tick(); + const now = store.peekSectionDetail(P, ROUTE); + const patches = store.cachedCrossPatches(P, ROUTE); + out.stale = { + swapped: now !== first, + heard, + adjust: designAt(now, 20).basin_adjust.x, + elevation: designAt(now, 60).design_elevation_m, + note: designAt(now, 40).structure_note, + patchAdjust: patchAt(patches, 20)?.basin_adjust?.x ?? null, + patchNote: patchAt(patches, 40)?.structure_note ?? null, + sessionAdjust: designAt(sessionNow(), 20).basin_adjust.x, + sessionNote: designAt(sessionNow(), 40).structure_note, + }; + + // ② 같은 판 — 새로고침(메모리 비움 흉내) · 서버 = 세션 → 그대로(갈아끼움 · 알림 없음) + store.invalidateSectionDetail(P, ROUTE); + state.writeState("section-detail", server, P, ROUTE); + release = open(); + heard = 0; + const again = await store.loadSectionDetail(P, ROUTE); + release(); + await tick(); + out.same = { kept: store.peekSectionDetail(P, ROUTE) === again, heard }; + + // ③ 서버 실패 · 엉뚱한 답 — 세션 그대로 + store.invalidateSectionDetail(P, ROUTE); + state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); + const saved = server; + server = null; + release = open(); + const broken = await store.loadSectionDetail(P, ROUTE); + release(); + await tick(); + out.broken = store.peekSectionDetail(P, ROUTE) === broken; + server = saved; + + // ④ 노선 초안 hold(옛 상세) · 갈아끼움 뒤 서버가 답함 → 초안을 버리면 새 판 + store.invalidateSectionDetail(P, ROUTE); + state.writeState("section-detail", canon(1, "A", 100), P, ROUTE); + release = open(); + const held = await store.loadSectionDetail(P, ROUTE); + const hold = store.holdSectionDetail(P, ROUTE, held); + const draftStations = [0, 25, 45, 60].map((c) => ({ + key: `at:${c}`, + chainageM: c, + kind: "grid", + x: c, + y: 0, + left: [0, 1], + azimuthDeg: 0, + ground: null, + })); + await store.swapSectionStations(P, ROUTE, draftStations); + const swappedDraft = store.peekSectionDetail(P, ROUTE) !== held; + release(); + await tick(); + const draftKept = store.peekSectionDetail(P, ROUTE).cross_sections.map((s) => s.chainage_m); + const back = await store.restoreSectionDetail( + P, + ROUTE, + hold, + [0, 20, 40, 60].map((c) => ({ key: `at:${c}`, chainageM: c })), + ); + out.hold = { + swappedDraft, + draftKept, + renewed: back && back.detail !== held, + adjust: back && designAt(back.detail, 20).basin_adjust.x, + same: back && back.same, + }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/test_384_3_planted_base.py b/resources/tester/test_384_3_planted_base.py index ce67887a4..3b4fb5f21 100644 --- a/resources/tester/test_384_3_planted_base.py +++ b/resources/tester/test_384_3_planted_base.py @@ -71,7 +71,10 @@ def test_저장_기준_입력이_심는_측점(planted): "both" ] # 꺼진 종류도 assert planted["noDetail"] is True # 상세 없음 = 종단도 기준 - assert planted["pvi"] == 2 + # 저장 꼭짓점(양 끝) + 심은 측점마다 세운 꼭짓점(427 · seatBornNodes) + born = [c for k, c in planted["both"] if k == "irregular" and 0 < c < 60] + assert planted["pviAt"] == sorted([0, 60, *born]) + assert planted["pvi"] == 2 + len(born) def test_늦게_심어도_안_돎(r): diff --git a/resources/tester/test_384_7_section_canon.py b/resources/tester/test_384_7_section_canon.py index 957ee2712..45b95eb2a 100644 --- a/resources/tester/test_384_7_section_canon.py +++ b/resources/tester/test_384_7_section_canon.py @@ -96,7 +96,8 @@ def test_열_때_세션_상세_한_번만_풂() -> None: def test_세션_상세_쓰는_곳_받을_때_하나() -> None: store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8") code = re.sub(r"/\*.*?\*/|//[^\n]*", "", store, flags=re.S) - assert code.count('writeState("section-detail"') == 1 # 서버에서 받은 정본만 + # 서버에서 받은 정본만 — 받을 때 · 세션 판 확인(451 · 433-16)이 받은 새 판 + assert re.findall(r'writeState\("section-detail", (\w+),', code) == ["detail", "fresh"] cross = (ROOT / "A00_Common" / "design" / "design_pipeline_cross.ts").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_451_session_canon.py b/resources/tester/test_451_session_canon.py new file mode 100644 index 000000000..0848624e7 --- /dev/null +++ b/resources/tester/test_451_session_canon.py @@ -0,0 +1,71 @@ +"""451 B06 세션 캐시 판 · 숨은 종단 보기 관 id 장부(433-16 · 440-3). + +① 새로고침 · 새 탭 = 세션 상세로 먼저 섬 · 뒤에서 서버 판을 물음 — 다른 탭이 저장해 다르면 서버 값 위에 + 이 탭에서 고친 사용자 칸만 · [저장] patch 바탕도 서버 값 · 세션 = 서버 정본 그대로 +② 같은 판 · 서버 실패 = 세션 그대로(갈아끼움 · 알림 없음) +③ 노선 초안이 옛 상세를 쥔 채 서버 판이 바뀜 — 초안을 버리면 새 판 +④ 숨은 종단 보기도 관 세션 id 장부를 돌림(평면 마커 누름 = 초안 자리) · 그리기는 보일 때 +TS 셈은 `helper_451_session_canon.cjs`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_451_session_canon.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_451_session_canon.cjs") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_첫_화면은_세션_뒤에서_판_확인(got) -> None: + assert got["firstFromSession"] is True + + +def test_판이_다르면_서버_값_위에_내_칸만(got) -> None: + stale = got["stale"] + assert stale["swapped"] is True and stale["heard"] == 1 + assert stale["adjust"] == 2 and stale["elevation"] == 101 # 다른 탭이 저장한 값 + assert stale["note"] == "mine" # 이 탭에서 고친 칸 + assert stale["patchAdjust"] == 2 and stale["patchNote"] == "mine" # [저장] 바탕 + assert stale["sessionAdjust"] == 2 and stale["sessionNote"] == "A" # 세션 = 서버 정본 + + +def test_같은_판_실패는_그대로(got) -> None: + assert got["same"] == {"kept": True, "heard": 0} + assert got["broken"] is True + + +def test_초안_hold_는_버리면_새_판(got) -> None: + hold = got["hold"] + assert hold["swappedDraft"] is True and hold["draftKept"] == [0, 25, 45, 60] + assert hold["renewed"] is True and hold["adjust"] == 2 and hold["same"] is True + + +def test_숨은_종단_보기도_관_id_장부() -> None: + view = (ROOT / "A00_Common/design/design_view_profile.ts").read_text(encoding="utf-8") + follow = view[view.index("const followFacilities") : view.index("const selectGraphAt")] + assert "if (!host.visible()) return void (lastPipes && shownPipes(lastPipes));" in follow + changed = view[view.index("onPipesChanged: (pipes) => {") :] + changed = changed[: changed.index("},")] + assert "else shownPipes(pipes);" in changed