From 02f15c3d6ac66785d817f6dfc4df5097ff3d7a59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 08:47:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B0=80=EC=9D=B4=EB=93=9C):=20538-2=20[?= =?UTF-8?q?=3F]=20=3D=20=EB=84=93=EC=9D=80=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=ED=95=9C=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=C2=B7=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=EB=AC=B6=EC=9D=8C=20=E2=86=92=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=E2=86=92=20=EA=B8=B0=EB=8A=A5=20=C2=B7=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EC=A3=BC=EC=9D=98/=EC=B0=B8=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20B01=20[=3F]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [?] = openModal wide · 왼쪽 차례(임도 설계 B → 안정성평가 E → 예정노선 D · 지금 페이지 펼침) · 오른쪽 = 모든 페이지 이어 그림 · 처음 = 보이는 data-help-topic 절 → 지금 페이지 절 → 맨 앞 · B05↔B06 짝 걷음 - 그리기: 덩어리마다 원문 줄(data-src) · `![설명](이름.webp)` → figure(loading=lazy · ?v=판) · 없는 그림 = 관리자 「그림 없음 — 더블클릭해 지정」 / 일반 「그림 준비 중」 · `> 주의:` · `> 참고:` 상자 · `[글](B06/00_설명서)` → 모달 안 이동 · 페이지를 안 넘기면(B01 지식 채팅) 옛 출력 그대로 - GET /api/help 에 페이지마다 그림 판 · GET /api/help/{page}/img/{name}(이름 규칙 · no-cache) · 옛 GET /api/help/{page} 걷음(모달이 한 가이드로 바뀜) - B01 대시보드 머리 역할 배지 왼쪽에 [?] - 시험 test_538_help_guide.py(변환 글 · 그림 404 · admin 칸) + helper_538_help_render.cjs Co-Authored-By: Claude Opus 5.5 --- B01_Dashboard/B01_Dashboard_UI_Page.ts | 8 +- common_util/common_util_help_router.py | 42 +++- resources/tester/helper_538_help_render.cjs | 54 +++++ resources/tester/test_538_help_guide.py | 132 +++++++++++ ui_template/ui_template_help.css | 114 +++++++++- ui_template/ui_template_help.ts | 236 ++++++++++++++++---- 6 files changed, 526 insertions(+), 60 deletions(-) create mode 100644 resources/tester/helper_538_help_render.cjs create mode 100644 resources/tester/test_538_help_guide.py diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 770e60116..5194737e0 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -8,6 +8,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; +import { createHelpButton } from "@ui/ui_template_help"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createStepBar } from "@ui/ui_template_workflow_layout"; import { setDrawerSteps } from "../A00_Common/app_shell"; @@ -358,7 +359,10 @@ function buildPage(state: DashboardState): HTMLElement { roleLabel(state.user.role), state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral", ); - tag.classList.add("b01-dashboard__role"); - layout.root.querySelector(".ui-general-layout__header")?.append(tag); + // [?] 사용자 가이드(538-2) = 배지 왼쪽 — 같은 자리 묶음 + const head = document.createElement("span"); + head.className = "b01-dashboard__role ui-help-head"; + head.append(createHelpButton(), tag); + layout.root.querySelector(".ui-general-layout__header")?.append(head); return layout.root; } diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index 20571c3f7..597efb0ae 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -4,6 +4,7 @@ 파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름). `_` 로 시작하는 파일 · 그림은 항목 아님. 판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목. +그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`. """ from __future__ import annotations @@ -14,6 +15,7 @@ from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse from common_util.common_util_auth import verify_session @@ -21,16 +23,35 @@ router = APIRouter(prefix="/api/help", tags=["help"]) HELP_ROOT = Path(__file__).resolve().parent.parent / "A05_EduDetail" / "guide" PAGE_RE = re.compile(r"^[BDE]\d{2}$") +IMG_RE = re.compile(r"^[A-Za-z0-9_-]+\.(webp|png|jpg)$") def file_version(blob: bytes) -> str: return hashlib.sha256(blob).hexdigest()[:16] -def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: +def image_version(path: Path) -> str: + return format(path.stat().st_mtime_ns, "x") + + +def page_folder(page: str, root: Path = HELP_ROOT) -> Path: if not PAGE_RE.match(page): raise HTTPException(status_code=404, detail="없는 페이지") - folder = root / page + return root / page + + +def image_path(page: str, name: str, root: Path = HELP_ROOT) -> Path: + if not IMG_RE.match(name): + raise HTTPException(status_code=404, detail="없는 그림") + return page_folder(page, root) / name + + +def read_images(folder: Path) -> dict[str, str]: + return {p.name: image_version(p) for p in sorted(folder.iterdir()) if IMG_RE.match(p.name)} + + +def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: + folder = page_folder(page, root) items = [] for path in sorted(folder.glob("*.md")) if folder.is_dir() else []: if path.name.startswith("_"): @@ -62,6 +83,7 @@ def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]: "page": folder.name, "title": manual["title"] if manual else folder.name, "items": items, + "images": read_images(folder), } ) return pages @@ -69,9 +91,17 @@ def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]: @router.get("") async def get_guide(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]: - return {"pages": read_guide(), "admin": session.get("role") == "SYSTEM_ADMIN"} + return {"pages": read_guide(HELP_ROOT), "admin": session.get("role") == "SYSTEM_ADMIN"} -@router.get("/{page}") -async def get_help(page: str) -> dict[str, list[dict[str, str]]]: - return {"items": read_help(page)} +MEDIA = {b"RIFF": "image/webp", b"\x89PNG": "image/png"} + + +@router.get("/{page}/img/{name}") +def get_image(page: str, name: str) -> FileResponse: + path = image_path(page, name, HELP_ROOT) + if not path.is_file(): + raise HTTPException(status_code=404, detail="없는 그림") + with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름 + media = MEDIA.get(f.read(4), "image/jpeg") + return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"}) diff --git a/resources/tester/helper_538_help_render.cjs b/resources/tester/helper_538_help_render.cjs new file mode 100644 index 000000000..f3861692c --- /dev/null +++ b/resources/tester/helper_538_help_render.cjs @@ -0,0 +1,54 @@ +/* PLAN 538 단위검증 헬퍼 — 진짜 `ui_template_help.ts` 의 `renderHelpMarkdown` 을 Node 로 돌려 변환 글을 JSON 으로 냄. + * 화면 모듈(단추 · 모달 · 고침)은 빈 가짜로 끼움. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { + "@config/config_frontend": { + API_BASE_URL: "/api", + ROUTES: {}, + PLAN_STEP_ROUTES: [], + STAB_STEP_ROUTES: [], + }, + "./ui_template_elements": {}, + "./ui_template_modal": {}, + "./ui_template_help_edit": {}, +}; +const { load } = makeLoader({ root: ROOT, stubs: STUBS }); +const { renderHelpMarkdown } = load("ui_template", "ui_template_help.ts"); + +const ctx = (admin) => ({ page: "B05", images: { "b05_full.webp": "abc" }, admin }); +const DOC = [ + "# 종단 설계", // 0 + "", // 1 + "첫 문단 [표준 횡단](B06/00_설명서) · [돌쌓기](B05/구조물_돌쌓기(메))", // 2 + "둘째 줄", // 3 + "", // 4 + "- 하나", // 5 + "- 둘", // 6 + "", // 7 + "| 칸 | 뜻 |", // 8 + "| --- | --- |", // 9 + "| a | b |", // 10 + "", // 11 + "> 주의: 저장 전 확인", // 12 + "> 둘째 줄", // 13 + "", // 14 + "> 참고: 알아 둘 것", // 15 + "", // 16 + "![화면 통째](b05_full.webp)", // 17 + "![없는 그림](none.webp)", // 18 + "![나쁜 이름](../x.webp)", // 19 + '', // 20 +].join("\n"); + +process.stdout.write( + JSON.stringify({ + plain: renderHelpMarkdown(DOC), + admin: renderHelpMarkdown(DOC, ctx(true)), + user: renderHelpMarkdown(DOC, ctx(false)), + crlf: renderHelpMarkdown("# 가\r\n\r\n- 나\r\n- 다\r\n", ctx(false)), + }), +); diff --git a/resources/tester/test_538_help_guide.py b/resources/tester/test_538_help_guide.py new file mode 100644 index 000000000..8fbe49616 --- /dev/null +++ b/resources/tester/test_538_help_guide.py @@ -0,0 +1,132 @@ +"""PLAN 538 [?] 사용자 가이드 — 변환 글 · 그림 내려주기 · 바로 고침. + +짝: `ui_template/ui_template_help.ts`(`renderHelpMarkdown`) +— `helper_538_help_render.cjs` 가 Node 로 돌림 · +`common_util/common_util_help_router.py`(GET /api/help · 그림 · PUT · POST). +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_help_router as help_router +from common_util.common_util_auth import verify_session + +HERE = os.path.dirname(__file__) + + +@pytest.fixture(scope="module") +def html() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_538_help_render.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_덩어리마다_원문_줄(html) -> None: + admin = html["admin"] + for tag in [ + '

', + '

', + '

    ', + 'class="ui-markdown-table" data-src="8-10"', + 'data-src="12-13"', + '
    ' in html["crlf"] + + +def test_페이지_안_넘기면_옛_출력(html) -> None: + """B01 지식 채팅 — 원문 줄 · 그림 · 상자 · 링크 없음.""" + plain = html["plain"] + assert "data-src" not in plain and "종단 설계

첫 문단 [표준 횡단](B06/00_설명서)") + + +def test_그림_상자_링크(html) -> None: + admin, user = html["admin"], html["user"] + assert ( + '화면 통째' in admin + ) + assert "

화면 통째
" in admin + assert "그림 없음 — 더블클릭해 지정" in admin and "그림 없음" not in user + assert "그림 준비 중" in user + assert ( + '