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 * B03_FileInput_UI_Guide.ts
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시). * 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
* *
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로 * 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `A05_EduDetail/guide/B03/*.md` 로
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김. * 옮김(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 from __future__ import annotations
import hashlib
import re import re
from pathlib import Path 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"]) router = APIRouter(prefix="/api/help", tags=["help"])
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" HELP_ROOT = Path(__file__).resolve().parent.parent / "A05_EduDetail" / "guide"
PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-7])$") 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]]: 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 folder = root / page
items = [] items = []
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []: 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("# ")), "") 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 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}") @router.get("/{page}")
async def get_help(page: str) -> dict[str, list[dict[str, str]]]: async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
return {"items": read_help(page)} 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 import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi import HTTPException 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(): def test_every_page_has_manual_first():
for n in range(2, 10): for page in [f"B0{n}" for n in range(2, 10)] + [f"D0{n}" for n in range(1, 5)]:
items = read_help(f"B0{n}") items = read_help(page)
assert items and items[0]["key"] == "00_설명서" 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(): def test_title_order_and_live_edit(tmp_path):
tmp_path = Path(tempfile.mkdtemp())
page = tmp_path / "B05" page = tmp_path / "B05"
page.mkdir() page.mkdir()
(page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8") (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"] 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): def test_bad_page_refused(page):
with pytest.raises(HTTPException): with pytest.raises(HTTPException):
read_help(page) read_help(page)
def test_529_1_D_E_pages_open_empty(tmp_path): def test_missing_page_opens_empty(tmp_path):
assert read_help("D02", tmp_path) == [] and read_help("E07", tmp_path) == [] assert read_help("D02", tmp_path) == [] and read_help("B10", tmp_path) == []
def test_root_is_repo_resources(): def test_root_is_a05_guide():
assert HELP_ROOT.parts[-2:] == ("resources", "help") assert HELP_ROOT.parts[-2:] == ("A05_EduDetail", "guide")
assert HELP_ROOT.is_dir()
assert not (Path(HELP_ROOT).parents[1] / "resources" / "help").exists()
@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,
)
+1 -1
View File
@@ -4,7 +4,7 @@
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로 * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로
* `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번 * `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) * `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null)
* 기대 수치: * 기대 수치:
* - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) · * - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) ·
+1 -1
View File
@@ -1,7 +1,7 @@
/* ============================================================================= /* =============================================================================
* ui_template_help.ts * ui_template_help.ts
* [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용. * [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용.
* 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜). * 글 = `A05_EduDetail/guide/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜).
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.