Initial Release:
https://chatgpt.com/share/6941a2a4-7974-800f-8911-9ab0bf1e3873
This commit is contained in:
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally empty: enables unittest discovery for this package.
|
||||
40
tests/unit/test_backend_chezmoi.py
Normal file
40
tests/unit/test_backend_chezmoi.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from dotlinker.backends.chezmoi import ChezmoiBackend
|
||||
from dotlinker.backends.base import RunContext
|
||||
from dotlinker.model import Mapping
|
||||
|
||||
|
||||
class TestChezmoiBackend(unittest.TestCase):
|
||||
def test_runs_add(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = Path(td) / "zshrc"
|
||||
src.write_text("x", encoding="utf-8")
|
||||
|
||||
m = Mapping(name="zshrc", backend="chezmoi", src=str(src), dest=None)
|
||||
backend = ChezmoiBackend(exe="chezmoi")
|
||||
|
||||
with patch("subprocess.run") as run:
|
||||
backend.pull(m, RunContext(dry_run=False, verbose=False))
|
||||
run.assert_called_once()
|
||||
args, kwargs = run.call_args
|
||||
self.assertEqual(args[0][0:2], ["chezmoi", "add"])
|
||||
self.assertIn(str(src.resolve()), args[0])
|
||||
self.assertTrue(kwargs["check"])
|
||||
|
||||
def test_dry_run_does_not_execute(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = Path(td) / "zshrc"
|
||||
src.write_text("x", encoding="utf-8")
|
||||
|
||||
m = Mapping(name="zshrc", backend="chezmoi", src=str(src), dest=None)
|
||||
backend = ChezmoiBackend(exe="chezmoi")
|
||||
|
||||
with patch("subprocess.run") as run:
|
||||
backend.pull(m, RunContext(dry_run=True, verbose=False))
|
||||
run.assert_not_called()
|
||||
94
tests/unit/test_backend_cloud.py
Normal file
94
tests/unit/test_backend_cloud.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dotlinker.backends.cloud import CloudBackend
|
||||
from dotlinker.backends.base import RunContext
|
||||
from dotlinker.model import Mapping
|
||||
|
||||
|
||||
class TestCloudBackend(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
os.environ["DOTLINKER_TIMESTAMP"] = "20251216T170000"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("DOTLINKER_TIMESTAMP", None)
|
||||
|
||||
def test_copies_file_then_links_and_backs_up_src(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
src = root / "home" / ".gitconfig"
|
||||
dest = root / "cloud" / ".gitconfig"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
src.write_text("A", encoding="utf-8")
|
||||
|
||||
m = Mapping(name="gitconfig", backend="cloud", src=str(src), dest=str(dest))
|
||||
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
|
||||
|
||||
self.assertTrue(dest.exists())
|
||||
self.assertEqual(dest.read_text(encoding="utf-8"), "A")
|
||||
|
||||
self.assertTrue(src.is_symlink())
|
||||
self.assertEqual(src.resolve(), dest.resolve())
|
||||
|
||||
bak = src.with_name(src.name + ".20251216T170000.bak")
|
||||
self.assertTrue(bak.exists())
|
||||
self.assertEqual(bak.read_text(encoding="utf-8"), "A")
|
||||
|
||||
def test_backs_up_existing_dest_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
src = root / "home" / ".vimrc"
|
||||
dest = root / "cloud" / ".vimrc"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
src.write_text("NEW", encoding="utf-8")
|
||||
dest.write_text("OLD", encoding="utf-8")
|
||||
|
||||
m = Mapping(name="vimrc", backend="cloud", src=str(src), dest=str(dest))
|
||||
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
|
||||
|
||||
bak_dest = dest.with_name(dest.name + ".20251216T170000.bak")
|
||||
self.assertTrue(bak_dest.exists())
|
||||
self.assertEqual(bak_dest.read_text(encoding="utf-8"), "OLD")
|
||||
self.assertEqual(dest.read_text(encoding="utf-8"), "NEW")
|
||||
self.assertTrue(src.is_symlink())
|
||||
|
||||
def test_noop_when_already_linked(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
src = root / "home" / ".zshrc"
|
||||
dest = root / "cloud" / ".zshrc"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest.write_text("X", encoding="utf-8")
|
||||
src.symlink_to(dest)
|
||||
|
||||
m = Mapping(name="zshrc", backend="cloud", src=str(src), dest=str(dest))
|
||||
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
|
||||
|
||||
self.assertTrue(src.is_symlink())
|
||||
self.assertEqual(src.resolve(), dest.resolve())
|
||||
self.assertEqual(dest.read_text(encoding="utf-8"), "X")
|
||||
|
||||
def test_requires_dest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
src = root / "home" / ".x"
|
||||
src.parent.mkdir(parents=True, exist_ok=True)
|
||||
src.write_text("x", encoding="utf-8")
|
||||
|
||||
m = Mapping(name="x", backend="cloud", src=str(src), dest=None)
|
||||
with self.assertRaises(ValueError):
|
||||
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
|
||||
64
tests/unit/test_cli.py
Normal file
64
tests/unit/test_cli.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from dotlinker.cli import main
|
||||
|
||||
|
||||
class TestCli(unittest.TestCase):
|
||||
def _run(self, argv: list[str]) -> tuple[int, str]:
|
||||
buf = io.StringIO()
|
||||
with patch("sys.argv", ["doli"] + argv), redirect_stdout(buf):
|
||||
try:
|
||||
main()
|
||||
return 0, buf.getvalue()
|
||||
except SystemExit as e:
|
||||
return int(e.code or 0), buf.getvalue()
|
||||
|
||||
def test_no_subcommand_prints_help(self) -> None:
|
||||
rc, out = self._run([])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("usage:", out)
|
||||
self.assertIn("{pull,add}", out)
|
||||
|
||||
def test_add_writes_config(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.yaml"
|
||||
|
||||
rc, _ = self._run(
|
||||
["-c", str(cfg), "add", "-N", "zshrc", "-b", "chezmoi", "-s", "~/.zshrc"]
|
||||
)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(cfg.exists())
|
||||
text = cfg.read_text(encoding="utf-8")
|
||||
self.assertIn("zshrc", text)
|
||||
self.assertIn("chezmoi", text)
|
||||
|
||||
def test_pull_calls_backends(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "config.yaml"
|
||||
# Create config with two mappings
|
||||
cfg.write_text(
|
||||
"mappings:\n"
|
||||
" - name: zshrc\n"
|
||||
" backend: chezmoi\n"
|
||||
" src: ~/.zshrc\n"
|
||||
" - name: nvim\n"
|
||||
" backend: cloud\n"
|
||||
" src: ~/.config/nvim\n"
|
||||
" dest: ~/Nextcloud/dotfiles/.config/nvim\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("dotlinker.cli.ChezmoiBackend.pull") as chez_pull, patch(
|
||||
"dotlinker.cli.CloudBackend.pull"
|
||||
) as cloud_pull:
|
||||
rc, _ = self._run(["-c", str(cfg), "pull"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(chez_pull.call_count, 1)
|
||||
self.assertEqual(cloud_pull.call_count, 1)
|
||||
57
tests/unit/test_config.py
Normal file
57
tests/unit/test_config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dotlinker.config import default_config_path, load_config, save_config, upsert_mapping
|
||||
from dotlinker.model import Mapping
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_default_config_path_respects_xdg(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
xdg = Path(td) / "xdg"
|
||||
os.environ["XDG_CONFIG_HOME"] = str(xdg)
|
||||
|
||||
p = default_config_path()
|
||||
self.assertEqual(p, xdg / "dotlinker" / "config.yaml")
|
||||
|
||||
def test_load_config_missing_returns_empty(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "missing.yaml"
|
||||
self.assertEqual(load_config(cfg), [])
|
||||
|
||||
def test_save_and_load_roundtrip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg = Path(td) / "cfg.yaml"
|
||||
mappings = [
|
||||
Mapping(name="zshrc", backend="chezmoi", src="~/.zshrc", dest=None),
|
||||
Mapping(
|
||||
name="nvim",
|
||||
backend="cloud",
|
||||
src="~/.config/nvim",
|
||||
dest="~/Nextcloud/dotfiles/.config/nvim",
|
||||
),
|
||||
]
|
||||
|
||||
save_config(cfg, mappings)
|
||||
loaded = load_config(cfg)
|
||||
self.assertEqual(loaded, mappings)
|
||||
|
||||
def test_upsert_adds_when_missing(self) -> None:
|
||||
out = upsert_mapping([], Mapping("a", "chezmoi", "~/.a", None), replace=False)
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0].name, "a")
|
||||
|
||||
def test_upsert_rejects_duplicate_without_replace(self) -> None:
|
||||
items = [Mapping("a", "chezmoi", "~/.a", None)]
|
||||
with self.assertRaises(ValueError):
|
||||
upsert_mapping(items, Mapping("a", "cloud", "~/.a", "~/x"), replace=False)
|
||||
|
||||
def test_upsert_replaces_with_replace(self) -> None:
|
||||
items = [Mapping("a", "chezmoi", "~/.a", None)]
|
||||
out = upsert_mapping(items, Mapping("a", "cloud", "~/.a", "~/x"), replace=True)
|
||||
self.assertEqual(out[0].backend, "cloud")
|
||||
self.assertEqual(out[0].dest, "~/x")
|
||||
Reference in New Issue
Block a user