From 23cf91e2dfa94210569ea71e2c857d87b74cdcfe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:36:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=EC=A2=85=EB=8B=A8=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EA=B8=80=20=E2=80=94=20=EA=B8=B0=ED=83=80=20=3D=20?= =?UTF-8?q?=EC=86=90=20=EC=9D=B4=EB=A6=84=20=EB=A8=B8=EB=A6=AC=20+=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=B9=88=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=3D=20=EA=B8=B0=ED=83=80=20N(54-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기타 알약 = 손 이름 앞 3글자(빈칸 뺌 · 다른 손 이름과 머리가 같으면 갈릴 때까지 늘림) + 목록과 같은 번호 - 빈 이름 = 「기타 N」 · 다른 종류 알약 · 툴팁 · 목록 이름 그대로 - structurePillTexts 한 곳이 B05 · B06 알약을 함께 그림 · 시험 test_54_9_etc_pill Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- .../B05_Profile_UI_Structures_Marks.ts | 75 +++++++++++---- resources/tester/helper_54_9_etc_pill.cjs | 95 +++++++++++++++++++ resources/tester/test_54_9_etc_pill.py | 74 +++++++++++++++ 3 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_54_9_etc_pill.cjs create mode 100644 resources/tester/test_54_9_etc_pill.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index e0913e2e7..895276fd4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -38,6 +38,8 @@ const ROW_GAP_PX = 2; const LANE_PADDING_PX = 2; /** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */ const OVERLAP_PX = 52; +/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ +const ETC_PILL_CHARS = 3; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -115,6 +117,53 @@ function rowAssignments( return rows; } +/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */ +function distinctHeads(names: ReadonlyArray): Map { + const letters = names.map((name) => [...name.replace(/\s+/g, "")]); + return new Map( + names.map((name, index) => { + const own = letters[index]; + let length = ETC_PILL_CHARS; + letters.forEach((other, otherIndex) => { + if (otherIndex === index) return; + let common = 0; + while (common < own.length && own[common] === other[common]) common += 1; + length = Math.max(length, common + 1); + }); + return [name, own.slice(0, length).join("")]; + }), + ); +} + +/** 표시 이름 · 알약 글(구조물 id 별). 표시 이름 = 좌측 목록과 같은 중복 번호(시점에 가까운 순 + * "이름 1/2…", 2026-08-19) — 툴팁 · 벌룬 제목이 씀. 알약 = 약호 + 그 번호. 기타는 약호가 모두 + * 「기타」라 손 이름 머리(distinctHeads) + 번호 · 빈 이름 = 「기타 N」(54-9). */ +export function structurePillTexts( + structures: ReadonlyArray, + typeMap: ReadonlyMap, +): Map { + const sorted = [...structures].sort( + (left, right) => structureAnchorM(left) - structureAnchorM(right), + ); + const rawNames = sorted.map( + (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, + ); + const numbered = numberDuplicateNames(rawNames); + const heads = distinctHeads([ + ...new Set(rawNames.filter((_name, index) => sorted[index].type_id === "etc")), + ]); + return new Map( + sorted.map((entry, index) => { + const name = numbered[index]; + const suffix = name.slice(rawNames[index].length); // " 1" 또는 "" + const head = etcName(entry) + ? heads.get(rawNames[index]) + : typeMap.get(entry.type_id)?.style?.abbr; + return [entry.structure_id ?? "", { name, pill: `${head ?? "?"}${suffix}` }]; + }), + ); +} + /** * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ @@ -127,28 +176,15 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const typeMap = new Map(options.types.map((type) => [type.type_id, type])); const rows = rowAssignments(options.structures, options.x); - // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순 - // "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다. - const anchorSorted = [...options.structures].sort( - (left, right) => structureAnchorM(left) - structureAnchorM(right), - ); - const numberedNames = numberDuplicateNames( - anchorSorted.map( - (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, - ), - ); - const displayNames = new Map( - anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), - ); + // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 + // 모두 이 번호를 따른다. + const texts = structurePillTexts(options.structures, typeMap); options.structures.forEach((structure) => { const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); - const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게) - const displayName = displayNames.get(id) ?? rawName; - // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). - const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; + const { name: displayName, pill } = texts.get(id) ?? { name: structure.type_id, pill: "?" }; const anchorPx = options.x(structureAnchorM(structure)); const selected = options.selectedId === id; // 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다. @@ -185,8 +221,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement mark.style.top = `${top}px`; mark.style.borderColor = type?.style?.color ?? "#888"; mark.style.color = type?.style?.color ?? "#888"; - // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호. - mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`; + // 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호 · + // 기타는 손 이름 머리(54-9). + mark.textContent = pill; mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; diff --git a/resources/tester/helper_54_9_etc_pill.cjs b/resources/tester/helper_54_9_etc_pill.cjs new file mode 100644 index 000000000..4cb1a00d6 --- /dev/null +++ b/resources/tester/helper_54_9_etc_pill.cjs @@ -0,0 +1,95 @@ +/* 54-9 종단 알약 글 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소만 흉내). + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; + +const STUBS = { + "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 }, +}; + +function loadTs(file) { + const full = path.resolve(file); + 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 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const marks = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts")); + +const typeMap = new Map([ + ["etc", { type_id: "etc", name: "기타", style: { abbr: "기타" }, options: [] }], + [ + "retaining_wall", + { type_id: "retaining_wall", name: "옹벽", style: { abbr: "옹" }, options: [] }, + ], +]); +const etc = (id, chainage, name) => ({ + structure_id: id, + type_id: "etc", + placement: "point", + chainage_m: chainage, + options: name === undefined ? {} : { name }, +}); +const wall = (id, chainage) => ({ + structure_id: id, + type_id: "retaining_wall", + placement: "point", + chainage_m: chainage, + options: {}, +}); +const run = (structures) => + Object.fromEntries( + [...marks.structurePillTexts(structures, typeMap)].map(([id, text]) => [id, text]), + ); + +const out = { + // 53-2 시나리오 — 같은 이름 둘 · 다른 이름 · 빈 이름 둘(빈칸 이름 = 빈 이름) + scenario: run([ + etc("e1", 40, "배수로정비"), + etc("e2", 60, "울타리"), + etc("e3", 80, ""), + etc("e4", 100, "배수로정비"), + etc("e5", 120, " "), + ]), + // 머리가 같은 다른 이름 — 갈릴 때까지 늘림 · 이름이 남의 머리면 그 이름 그대로 + prefix: run([ + etc("p1", 10, "콘크리트 블록"), + etc("p2", 20, "콘크리트 옹벽"), + etc("p3", 30, "배수로"), + etc("p4", 40, "배수로공"), + etc("p5", 50, "기타 시설"), + etc("p6", 60), + ]), + // 다른 종류 규칙은 그대로 — 약호 + 중복 번호 · 기타 하나 빈 이름 = 「기타」 + others: run([wall("w1", 10), wall("w2", 30), etc("x1", 20, "가로등"), etc("x2", 40)]), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_9_etc_pill.py b/resources/tester/test_54_9_etc_pill.py new file mode 100644 index 000000000..0adec35b0 --- /dev/null +++ b/resources/tester/test_54_9_etc_pill.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨. + +• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」 +• 머리가 같은 다른 이름은 갈릴 때까지 늘림 +• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로 +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`). +B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_9_etc_pill.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_53_2_기타_다섯_알약이_서로_다르다(out): + pills = {key: value["pill"] for key, value in out["scenario"].items()} + assert pills == { + "e1": "배수로 1", + "e2": "울타리", + "e3": "기타 1", + "e4": "배수로 2", + "e5": "기타 2", + } + assert len(set(pills.values())) == 5 + + +def test_툴팁_이름은_손_이름_그대로(out): + names = [value["name"] for value in out["scenario"].values()] + assert names == ["배수로정비 1", "울타리", "기타 1", "배수로정비 2", "기타 2"] + + +def test_머리가_같은_다른_이름은_갈릴_때까지_늘린다(out): + pills = {key: value["pill"] for key, value in out["prefix"].items()} + assert pills == { + "p1": "콘크리트블", + "p2": "콘크리트옹", + "p3": "배수로", + "p4": "배수로공", + "p5": "기타시", + "p6": "기타", + } + + +def test_다른_종류_알약은_약호와_번호_그대로(out): + pills = {key: value["pill"] for key, value in out["others"].items()} + assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"} + + +def test_알약_레인이_글을_한_곳에서_받는다(): + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"), encoding="utf-8" + ) as handle: + text = handle.read() + assert "mark.textContent = pill;" in text + assert text.count("structurePillTexts(") == 2 # 정의 하나 · 레인 하나