feat(가이드): 538-1 글 자리 A05_EduDetail/guide 로 옮김 + GET /api/help 한 가이드 읽기

- resources/help → A05_EduDetail/guide (git mv · 이력 남김) · 참조 고침(서버 · 시험 · B03 주석 · ui_checks)
- GET /api/help = 모든 페이지 [{page, title, items:[{key, title, text, version}]}] + admin
  · 페이지 = ^[BDE]\d{2}$ 폴더 · `_` 파일 · 그림 뺌 · version = sha256 앞 16자 · 페이지 제목 = 00_설명서 제목
- GET /api/help/{page} 는 538-2 까지 살림 · B01 폴더 허용
- 시험: 글 내용 검사(명사형 · 400 자) 걷음 — 시스템 관리자가 화면에서 바로 고친 글이
  지킴 시험을 빨갛게 하지 않게(요구서 359 4장 6) · 차례 · 판 · `_` 파일 · 나쁜 폴더 · 경로 탈출 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:37:33 +09:00
co-authored by Claude Opus 5.5
parent 378a8e21ef
commit 0f32c241ca
61 changed files with 110 additions and 37 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
* B03_FileInput_UI_Guide.ts
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
*
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `A05_EduDetail/guide/B03/*.md` 로
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김.
* ========================================================================== */
+49 -6
View File
@@ -1,20 +1,30 @@
"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌.
"""[?] 사용자 가이드(54-2 · 538) — `A05_EduDetail/guide/<페이지>/*.md` 를 부를 때마다 읽어 줌.
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
`_` 로 시작하는 파일 · 그림은 항목 아님.
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
"""
from __future__ import annotations
import hashlib
import re
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from common_util.common_util_auth import verify_session
router = APIRouter(prefix="/api/help", tags=["help"])
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-7])$")
HELP_ROOT = Path(__file__).resolve().parent.parent / "A05_EduDetail" / "guide"
PAGE_RE = re.compile(r"^[BDE]\d{2}$")
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]]:
@@ -23,12 +33,45 @@ def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
folder = root / page
items = []
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
text = path.read_text(encoding="utf-8-sig")
if path.name.startswith("_"):
continue
blob = path.read_bytes()
text = blob.decode("utf-8-sig")
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
items.append({"key": path.stem, "title": head or path.stem, "text": text})
items.append(
{
"key": path.stem,
"title": head or path.stem,
"text": text,
"version": file_version(blob),
}
)
return items
def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]:
"""모든 페이지 — 폴더 이름 순 · 글 없는 폴더는 뺌."""
pages = []
for folder in sorted(p for p in root.iterdir() if p.is_dir() and PAGE_RE.match(p.name)):
items = read_help(folder.name, root)
if not items:
continue
manual = next((i for i in items if i["key"] == "00_설명서"), None)
pages.append(
{
"page": folder.name,
"title": manual["title"] if manual else folder.name,
"items": items,
}
)
return pages
@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"}
@router.get("/{page}")
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
return {"items": read_help(page)}
+58 -28
View File
@@ -1,23 +1,34 @@
"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜)."""
"""54-2 · 538 [?] 가이드 — 서버가 A05_EduDetail/guide/<페이지>/*.md 를 부를 때마다 읽음.
import re
글 내용 검사(명사형 · 길이)는 두지 않음 — 관리자가 화면에서 고친 글이 시험을 빨갛게 안 하게.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from fastapi import HTTPException
from common_util.common_util_help_router import HELP_ROOT, read_help
from common_util.common_util_help_router import HELP_ROOT, file_version, read_guide, read_help
@pytest.fixture
def tmp_path(): # 기본 pytest 임시 폴더가 이 PC 에서 막힘 — 직접 만듦
path = Path(tempfile.mkdtemp())
yield path
shutil.rmtree(path, ignore_errors=True)
def test_every_page_has_manual_first():
for n in range(2, 10):
items = read_help(f"B0{n}")
assert items and items[0]["key"] == "00_설명서"
for page in [f"B0{n}" for n in range(2, 10)] + [f"D0{n}" for n in range(1, 5)]:
items = read_help(page)
assert items and items[0]["key"] == "00_설명서", page
for n in range(1, 8):
assert read_help(f"E0{n}")[0]["key"] == "00_설명서"
def test_title_order_and_live_edit():
tmp_path = Path(tempfile.mkdtemp())
def test_title_order_and_live_edit(tmp_path):
page = tmp_path / "B05"
page.mkdir()
(page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8")
@@ -29,30 +40,49 @@ def test_title_order_and_live_edit():
assert "고친 글" in read_help("B05", tmp_path)[1]["text"]
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E08", "C01"])
def test_version_is_file_hash_and_changes(tmp_path):
page = tmp_path / "B05"
page.mkdir()
path = page / "00_설명서.md"
path.write_text("# 종단\n", encoding="utf-8")
first = read_help("B05", tmp_path)[0]["version"]
assert first == file_version(path.read_bytes()) and len(first) == 16
path.write_text("# 종단\n고침\n", encoding="utf-8")
assert read_help("B05", tmp_path)[0]["version"] != first
def test_guide_pages_order_title_and_skips(tmp_path):
for name in ["E02", "B05", "B01", "b03", "X01", "B5", "_B04", "D01"]:
(tmp_path / name).mkdir()
(tmp_path / "B05" / "00_설명서.md").write_text("# 종단 설계\n", encoding="utf-8")
(tmp_path / "B05" / "_scenes.md").write_text("# 장면\n", encoding="utf-8")
(tmp_path / "B05" / "그림.webp").write_bytes(b"x")
(tmp_path / "B01" / "00_설명서.md").write_text("# 대시보드\n", encoding="utf-8")
(tmp_path / "E02" / "10_칸_뜻.md").write_text("# 칸\n", encoding="utf-8")
for name in ["b03", "X01", "B5", "_B04"]:
(tmp_path / name / "00_설명서.md").write_text("# 나쁨\n", encoding="utf-8")
pages = read_guide(tmp_path)
assert [p["page"] for p in pages] == ["B01", "B05", "E02"] # D01 = 글 없음 · 나쁜 폴더 뺌
assert [p["title"] for p in pages] == ["대시보드", "종단 설계", "E02"]
assert [i["key"] for i in pages[1]["items"]] == ["00_설명서"] # `_` 파일 · 그림은 항목 아님
def test_real_guide_reads_all_pages():
pages = {p["page"] for p in read_guide()}
assert {"B02", "B05", "B06", "D01", "E02", "E07"} <= pages
@pytest.mark.parametrize("page", ["../B05", "b05", "B5", "C01", "B05/..", "..", "B100"])
def test_bad_page_refused(page):
with pytest.raises(HTTPException):
read_help(page)
def test_529_1_D_E_pages_open_empty(tmp_path):
assert read_help("D02", tmp_path) == [] and read_help("E07", tmp_path) == []
def test_missing_page_opens_empty(tmp_path):
assert read_help("D02", tmp_path) == [] and read_help("B10", tmp_path) == []
def test_root_is_repo_resources():
assert HELP_ROOT.parts[-2:] == ("resources", "help")
@pytest.mark.parametrize(
"page", ["D01", "D02", "D03", "D04", "E01", "E02", "E03", "E04", "E05", "E06", "E07"]
)
def test_d_e_pages_have_manual(page):
items = read_help(page)
assert items and items[0]["key"] == "00_설명서"
assert len(items[0]["text"]) > 400
for item in items: # 명사형 종결 — 합쇼체 · 해요체 · 한다체 금지
for line in item["text"].splitlines():
assert not re.search(r"(합니다|습니다|해요|세요|한다|된다|있다|없다)[.!]?\s*$", line), (
page,
line,
)
def test_root_is_a05_guide():
assert HELP_ROOT.parts[-2:] == ("A05_EduDetail", "guide")
assert HELP_ROOT.is_dir()
assert not (Path(HELP_ROOT).parents[1] / "resources" / "help").exists()
+1 -1
View File
@@ -4,7 +4,7 @@
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로
* `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번
* 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로)
* 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 →
* 파일 고침 반영: 셸이 `A05_EduDetail/guide/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 →
* `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null)
* 기대 수치:
* - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) ·
+1 -1
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* ui_template_help.ts
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
* 글 = `A05_EduDetail/guide/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.