feat(가이드): 538-3 글 더블클릭 바로 고침 — 시스템 관리자만 · 판 409 · 원자 쓰기

- 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 textarea(높이 자동)
  · 바깥 누름 · Ctrl+Enter 저장(안 바뀌면 요청 없음) · Esc 취소 · 한글 조합 중 Enter · Esc 무시
  · 고침 열린 채 모달 닫으면 저장 · 저장 뒤 그 항목만 다시 그림
- PUT /api/help/{page}/{key} {version, start, end, text} — require_system_admin(403) · 판 다르면 409 ·
  그 줄만 갈아 임시 파일 → os.replace · 줄바꿈(CRLF) · BOM 그대로 · 409 면 다시 불러오고 내 글은 절 머리에 보여 둠
- 모달 Esc: 몸이 막은 Esc · 한글 조합 중 Esc 는 안 닫음
- 시험: 줄 바꿈 · 빈 글 지움 · CRLF/BOM · 409 · 403 · 나쁜 범위/항목 · 원자 쓰기 실패 때 원본 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:53:34 +09:00
co-authored by Claude Opus 5.5
parent 02f15c3d6a
commit 73bdb9bc5b
6 changed files with 297 additions and 3 deletions
+74 -1
View File
@@ -88,7 +88,7 @@ def test_html_은_먼저_막음(html) -> None:
def root(monkeypatch):
path = Path(tempfile.mkdtemp())
(path / "B05").mkdir()
(path / "B05" / "00_설명서.md").write_text("# 종단\n\n첫 문단\n", encoding="utf-8")
(path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode())
monkeypatch.setattr(help_router, "HELP_ROOT", path)
yield path
shutil.rmtree(path, ignore_errors=True)
@@ -130,3 +130,76 @@ def test_그림_내려주기_no_cache(root) -> None:
def test_그림_404(root, url) -> None:
(root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP")
assert client().get(url).status_code == 404
# ── 538-3 글 바로 고침 ────────────────────────────────────────────────────────
def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"):
body = {"version": version, "start": start, "end": end, "text": text}
return c.put(f"/api/help/B05/{key}", json=body)
def version_of(root: Path) -> str:
return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes())
def test_그_줄만_갈고_새_판(root) -> None:
old = version_of(root)
res = put(client(), old, 2, 2, "고친 문단\n둘째 줄")
assert res.status_code == 200
item = res.json()["item"]
text = (root / "B05" / "00_설명서.md").read_bytes().decode()
assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"]
assert item["version"] == version_of(root) != old
assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움
assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n"
def test_crlf_bom_그대로(root) -> None:
path = root / "B05" / "00_설명서.md"
path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n")
assert put(client(), version_of(root), 2, 2, "C").status_code == 200
assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n"
def test_판_다르면_409_파일_그대로(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409
assert (root / "B05" / "00_설명서.md").read_bytes() == before
def test_시스템_관리자만_403(root) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
for role in ["USER", "COMPANY_ADMIN"]:
assert put(client(role), version_of(root), 2, 2, "x").status_code == 403
assert (root / "B05" / "00_설명서.md").read_bytes() == before
@pytest.mark.parametrize(
("start", "end", "key", "code"),
[
(3, 2, "00_설명서", 400),
(0, 9, "00_설명서", 400),
(0, 0, "없음", 404),
(0, 0, "_manual", 404),
(0, 0, "..%2F..%2FCLAUDE", 404),
],
)
def test_나쁜_범위_항목(root, start, end, key, code) -> None:
assert put(client(), version_of(root), start, end, "x", key).status_code == code
def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
before = (root / "B05" / "00_설명서.md").read_bytes()
def boom(*_args):
raise OSError("쓰기 실패")
monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom)
with pytest.raises(OSError):
put(client(), version_of(root), 2, 2, "반쯤")
assert (root / "B05" / "00_설명서.md").read_bytes() == before
assert sorted(p.name for p in (root / "B05").iterdir()) == [
"00_설명서.md"
] # 임시 파일 남지 않음