Files
llm-wiki/harness/tests/test_fs_transaction.py
T

144 lines
6.9 KiB
Python

from __future__ import annotations
from pathlib import Path
import sys
import tempfile
import unittest
import unittest.mock
RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
sys.path.insert(0, str(RUNTIME))
import fs_transaction # noqa: E402
class ReplaceManySymlinkTest(unittest.TestCase):
"""vault cutover 이후 legacy 경로는 정본을 가리키는 심링크다.
``os.replace`` 는 심링크를 따라가지 않고 그 자리를 실파일로 갈아치운다. 이 원시가
심링크를 고려하지 않던 동안 writer 8곳 중 7곳이 legacy 경로를 그대로 넘기고 있었고,
쓰면 호환 심링크가 끊기면서 정본은 낡은 채로 남았다 — 그리고 조용했다.
"""
def _cutover_layout(self, root: Path) -> tuple[Path, Path]:
canonical = root / "vault" / "doc.md"
canonical.parent.mkdir(parents=True)
canonical.write_text("원본\n", encoding="utf-8")
legacy_dir = root / "raw"
legacy_dir.mkdir()
legacy = legacy_dir / "doc.md"
legacy.symlink_to("../vault/doc.md")
return canonical, legacy
def test_bytes_write_through_symlink_updates_canonical_and_keeps_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
canonical, legacy = self._cutover_layout(Path(tmp))
fs_transaction.replace_many({legacy: "수정됨\n".encode("utf-8")})
self.assertTrue(legacy.is_symlink(), "호환 심링크가 실파일로 대체되면 안 된다")
self.assertEqual(canonical.read_text(encoding="utf-8"), "수정됨\n")
def test_follow_symlinks_false_replaces_the_link_itself(self) -> None:
"""cutover/rollback 은 엔트리 종류 자체를 바꾸는 것이 목적이므로 예외가 필요하다."""
with tempfile.TemporaryDirectory() as tmp:
canonical, legacy = self._cutover_layout(Path(tmp))
fs_transaction.replace_many(
{legacy: "롤백\n".encode("utf-8")}, follow_symlinks=False
)
self.assertFalse(legacy.is_symlink())
self.assertEqual(legacy.read_text(encoding="utf-8"), "롤백\n")
self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n")
def test_symlink_value_is_always_installed_lexically(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
canonical, legacy = self._cutover_layout(Path(tmp))
other = canonical.parent / "other.md"
other.write_text("다른 정본\n", encoding="utf-8")
fs_transaction.replace_many({legacy: fs_transaction.SymlinkValue("../vault/other.md")})
self.assertTrue(legacy.is_symlink())
self.assertEqual(legacy.read_text(encoding="utf-8"), "다른 정본\n")
self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n")
def test_plain_file_write_is_unaffected(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "plain.md"
target.write_text("이전\n", encoding="utf-8")
fs_transaction.replace_many({target: "이후\n".encode("utf-8")})
self.assertFalse(target.is_symlink())
self.assertEqual(target.read_text(encoding="utf-8"), "이후\n")
def test_failed_commit_restores_symlink_entry_kind(self) -> None:
"""commit 도중 실패해도 심링크는 심링크로 복원돼야 한다.
롤백이 원래 엔트리 종류를 잃으면, 실패한 트랜잭션이 호환 계층을 조용히 실파일로
바꿔 놓는다 — 성공 경로를 고쳐도 실패 경로로 같은 손상이 들어올 수 있다.
"""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
canonical, legacy = self._cutover_layout(root)
second = root / "vault" / "second.md"
second.write_text("두번째\n", encoding="utf-8")
real_replace = fs_transaction.os.replace
calls: list[int] = []
def flaky(src, dst): # noqa: ANN001 - 테스트 더블
calls.append(1)
if len(calls) == 2:
raise OSError("주입된 commit 실패")
return real_replace(src, dst)
with unittest.mock.patch.object(fs_transaction.os, "replace", flaky):
with self.assertRaises(fs_transaction.TransactionError):
fs_transaction.replace_many({
legacy: "수정됨\n".encode("utf-8"),
second: "수정됨2\n".encode("utf-8"),
})
self.assertTrue(legacy.is_symlink(), "실패 롤백 후에도 심링크가 살아있어야 한다")
self.assertEqual(canonical.read_text(encoding="utf-8"), "원본\n")
self.assertEqual(second.read_text(encoding="utf-8"), "두번째\n")
class StageRepositoryTest(unittest.TestCase):
"""검증 스테이징은 심링크를 심링크로 복제해야 한다.
기본 ``copytree(symlinks=False)`` 는 각 링크를 따라가 실파일로 복제하므로, 스테이지가
split-brain(정본·링크가 독립된 실파일 2개)이 돼 candidate 가 legacy 경로로 정본을
우회 편집해도 투영/레이아웃 검증이 그 사실을 못 잡는다. 예전엔 이 함수가 세 벌 복사돼
있었고 한 벌만 고치면 나머지가 조용히 어긋났다 — 이제 단일 구현이다.
"""
def test_stage_preserves_symlinks_and_keeps_canonical_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "repo"
(root / "vault").mkdir(parents=True)
(root / "vault" / "doc.md").write_text("원본\n", encoding="utf-8")
(root / "raw").mkdir()
(root / "raw" / "doc.md").symlink_to("../vault/doc.md")
dest = Path(tmp) / "stage"
dest.mkdir()
fs_transaction.stage_repository(root, dest)
staged_legacy = dest / "raw" / "doc.md"
self.assertTrue(staged_legacy.is_symlink(), "스테이지의 legacy 경로는 심링크여야 한다")
# 정본을 바꾸면 심링크가 그대로 반영해야 한다(독립 실파일이 아니라).
(dest / "vault" / "doc.md").write_text("수정됨\n", encoding="utf-8")
self.assertEqual(staged_legacy.read_text(encoding="utf-8"), "수정됨\n")
def test_stage_skips_git_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "repo"
(root / ".git").mkdir(parents=True)
(root / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
(root / "keep.md").write_text("문서\n", encoding="utf-8")
dest = Path(tmp) / "stage"
dest.mkdir()
fs_transaction.stage_repository(root, dest)
self.assertFalse((dest / ".git").exists())
self.assertTrue((dest / "keep.md").is_file())
if __name__ == "__main__":
unittest.main()