This commit is contained in:
2026-02-12 09:42:59 +02:00
commit 906adb539d
87 changed files with 5288 additions and 0 deletions

0
tests/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

115
tests/test_action.py Normal file
View File

@@ -0,0 +1,115 @@
"""Tests for flow.core.action."""
from flow.core.action import Action, ActionExecutor
from flow.core.console import ConsoleLogger
def test_action_defaults():
a = Action(type="test", description="Test action")
assert a.status == "pending"
assert a.error is None
assert a.skip_on_error is True
assert a.os_filter is None
assert a.data == {}
def test_executor_register_and_execute(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
results = []
def handler(data):
results.append(data["key"])
executor.register("test-action", handler)
actions = [
Action(type="test-action", description="Do thing", data={"key": "value1"}),
Action(type="test-action", description="Do another", data={"key": "value2"}),
]
executor.execute(actions, current_os="linux")
assert results == ["value1", "value2"]
assert actions[0].status == "completed"
assert actions[1].status == "completed"
def test_executor_dry_run(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
executed = []
executor.register("test", lambda data: executed.append(1))
actions = [Action(type="test", description="Should not run")]
executor.execute(actions, dry_run=True)
assert executed == [] # Nothing executed
out = capsys.readouterr().out
assert "EXECUTION PLAN" in out
def test_executor_skip_on_error(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
def failing_handler(data):
raise RuntimeError("boom")
executor.register("fail", failing_handler)
actions = [
Action(type="fail", description="Will fail", skip_on_error=True),
Action(type="fail", description="Should still run", skip_on_error=True),
]
executor.execute(actions, current_os="linux")
assert actions[0].status == "skipped"
assert actions[1].status == "skipped"
def test_executor_critical_failure_stops(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
def failing_handler(data):
raise RuntimeError("critical failure")
executor.register("fail", failing_handler)
executor.register("ok", lambda data: None)
actions = [
Action(type="fail", description="Critical", skip_on_error=False),
Action(type="ok", description="Should not run"),
]
executor.execute(actions, current_os="linux")
assert actions[0].status == "failed"
assert actions[1].status == "pending" # Never reached
def test_executor_os_filter(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
executed = []
executor.register("test", lambda data: executed.append(data.get("name")))
actions = [
Action(type="test", description="Linux only", data={"name": "linux"}, os_filter="linux"),
Action(type="test", description="macOS only", data={"name": "macos"}, os_filter="macos"),
Action(type="test", description="Any OS", data={"name": "any"}),
]
executor.execute(actions, current_os="linux")
assert "linux" in executed
assert "any" in executed
assert "macos" not in executed
def test_executor_no_handler(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
actions = [Action(type="unknown", description="No handler registered")]
executor.execute(actions, current_os="linux")
assert actions[0].status == "skipped"

129
tests/test_bootstrap.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests for flow.commands.bootstrap — action planning."""
import pytest
from flow.commands.bootstrap import _get_profiles, _plan_actions
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo
@pytest.fixture
def ctx():
return FlowContext(
config=AppConfig(),
manifest={
"binaries": {
"neovim": {
"version": "0.10.4",
"source": "github:neovim/neovim",
"asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz",
"platform-map": {"linux-arm64": {"os": "linux", "arch": "arm64"}},
"install-script": "echo install",
},
},
},
platform=PlatformInfo(os="linux", arch="arm64", platform="linux-arm64"),
console=ConsoleLogger(),
)
def test_plan_empty_profile(ctx):
actions = _plan_actions(ctx, "test", {}, {})
assert actions == []
def test_plan_hostname(ctx):
actions = _plan_actions(ctx, "test", {"hostname": "myhost"}, {})
types = [a.type for a in actions]
assert "set-hostname" in types
def test_plan_locale_and_shell(ctx):
actions = _plan_actions(ctx, "test", {"locale": "en_US.UTF-8", "shell": "zsh"}, {})
types = [a.type for a in actions]
assert "set-locale" in types
assert "set-shell" in types
def test_plan_packages(ctx):
env_config = {
"packages": {
"standard": ["git", "zsh", "tmux"],
"binary": ["neovim"],
},
}
actions = _plan_actions(ctx, "test", env_config, {})
types = [a.type for a in actions]
assert "pm-update" in types
assert "install-packages" in types
assert "install-binary" in types
def test_plan_ssh_keygen(ctx):
env_config = {
"ssh_keygen": [
{"type": "ed25519", "comment": "test@host", "filename": "id_ed25519"},
],
}
actions = _plan_actions(ctx, "test", env_config, {})
types = [a.type for a in actions]
assert "generate-ssh-key" in types
def test_plan_runcmd(ctx):
env_config = {"runcmd": ["echo hello", "mkdir -p ~/tmp"]}
actions = _plan_actions(ctx, "test", env_config, {})
run_cmds = [a for a in actions if a.type == "run-command"]
assert len(run_cmds) == 2
def test_plan_requires(ctx):
env_config = {"requires": ["VAR1", "VAR2"]}
actions = _plan_actions(ctx, "test", env_config, {})
checks = [a for a in actions if a.type == "check-variable"]
assert len(checks) == 2
assert all(not a.skip_on_error for a in checks)
def test_plan_full_profile(ctx):
"""Test planning with a realistic linux-vm profile."""
env_config = {
"requires": ["TARGET_HOSTNAME"],
"os": "linux",
"hostname": "$TARGET_HOSTNAME",
"shell": "zsh",
"locale": "en_US.UTF-8",
"packages": {
"standard": ["zsh", "tmux", "git"],
"binary": ["neovim"],
},
"ssh_keygen": [{"type": "ed25519", "comment": "test"}],
"configs": ["bin"],
"runcmd": ["mkdir -p ~/projects"],
}
actions = _plan_actions(ctx, "linux-vm", env_config, {"TARGET_HOSTNAME": "myvm"})
assert len(actions) >= 8
types = [a.type for a in actions]
assert "check-variable" in types
assert "set-hostname" in types
assert "set-locale" in types
assert "set-shell" in types
assert "pm-update" in types
assert "install-packages" in types
assert "install-binary" in types
assert "generate-ssh-key" in types
assert "link-config" in types
assert "run-command" in types
def test_get_profiles_from_manifest(ctx):
ctx.manifest = {"profiles": {"linux": {"os": "linux"}}}
assert "linux" in _get_profiles(ctx)
def test_get_profiles_rejects_environments(ctx):
ctx.manifest = {"environments": {"legacy": {"os": "linux"}}}
with pytest.raises(RuntimeError, match="no longer supported"):
_get_profiles(ctx)

153
tests/test_cli.py Normal file
View File

@@ -0,0 +1,153 @@
"""Tests for CLI routing and command registration."""
import os
import subprocess
import sys
def _clean_env():
"""Return env dict without DF_* variables that trigger enter's guard."""
return {k: v for k, v in os.environ.items() if not k.startswith("DF_")}
def test_version():
result = subprocess.run(
[sys.executable, "-m", "flow", "--version"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "0.1.0" in result.stdout
def test_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "enter" in result.stdout
assert "dev" in result.stdout
assert "dotfiles" in result.stdout
assert "bootstrap" in result.stdout
assert "package" in result.stdout
assert "sync" in result.stdout
assert "completion" in result.stdout
def test_enter_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "enter", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "target" in result.stdout
assert "--dry-run" in result.stdout
def test_dotfiles_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "dotfiles", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "init" in result.stdout
assert "link" in result.stdout
assert "unlink" in result.stdout
assert "status" in result.stdout
assert "sync" in result.stdout
def test_bootstrap_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "bootstrap", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "run" in result.stdout
assert "list" in result.stdout
assert "show" in result.stdout
def test_package_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "package", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "install" in result.stdout
assert "list" in result.stdout
assert "remove" in result.stdout
def test_sync_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "sync", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "check" in result.stdout
assert "fetch" in result.stdout
assert "summary" in result.stdout
def test_dev_help():
result = subprocess.run(
[sys.executable, "-m", "flow", "dev", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert "create" in result.stdout
assert "exec" in result.stdout
assert "connect" in result.stdout
assert "list" in result.stdout
assert "stop" in result.stdout
assert "remove" in result.stdout
assert "respawn" in result.stdout
def test_enter_dry_run():
result = subprocess.run(
[sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"],
capture_output=True, text=True, env=_clean_env(),
)
assert result.returncode == 0
assert "ssh" in result.stdout
assert "personal.orb" in result.stdout
assert "tmux" in result.stdout
def test_enter_dry_run_no_tmux():
result = subprocess.run(
[sys.executable, "-m", "flow", "enter", "--dry-run", "--no-tmux", "personal@orb"],
capture_output=True, text=True, env=_clean_env(),
)
assert result.returncode == 0
assert "ssh" in result.stdout
assert "tmux" not in result.stdout
def test_enter_dry_run_with_user():
result = subprocess.run(
[sys.executable, "-m", "flow", "enter", "--dry-run", "root@personal@orb"],
capture_output=True, text=True, env=_clean_env(),
)
assert result.returncode == 0
assert "root@personal.orb" in result.stdout
def test_aliases():
"""Test that command aliases work."""
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:
result = subprocess.run(
[sys.executable, "-m", "flow", alias, "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0, f"Alias '{alias}' failed"
def test_dev_remove_alias():
result = subprocess.run(
[sys.executable, "-m", "flow", "dev", "rm", "--help"],
capture_output=True, text=True,
)
assert result.returncode == 0

59
tests/test_commands.py Normal file
View File

@@ -0,0 +1,59 @@
"""Tests for command modules — registration and target parsing."""
from flow.commands.enter import _parse_target
from flow.commands.container import _cname, _parse_image_ref
class TestParseTarget:
def test_full_target(self):
user, ns, plat = _parse_target("root@personal@orb")
assert user == "root"
assert ns == "personal"
assert plat == "orb"
def test_no_user(self):
user, ns, plat = _parse_target("personal@orb")
assert user is None
assert ns == "personal"
assert plat == "orb"
def test_namespace_only(self):
user, ns, plat = _parse_target("personal")
assert user is None
assert ns == "personal"
assert plat is None
class TestCname:
def test_adds_prefix(self):
assert _cname("api") == "dev-api"
def test_no_double_prefix(self):
assert _cname("dev-api") == "dev-api"
class TestParseImageRef:
def test_simple_image(self):
ref, repo, tag, label = _parse_image_ref("node")
assert ref == "registry.tomastm.com/node:latest"
assert tag == "latest"
def test_tm0_shorthand(self):
ref, repo, tag, label = _parse_image_ref("tm0/node")
assert "registry.tomastm.com" in ref
assert "node" in ref
def test_docker_shorthand(self):
ref, repo, tag, label = _parse_image_ref("docker/python")
assert "docker.io" in ref
assert "python" in ref
def test_with_tag(self):
ref, repo, tag, label = _parse_image_ref("node:20")
assert tag == "20"
assert ":20" in ref
def test_full_registry(self):
ref, repo, tag, label = _parse_image_ref("ghcr.io/user/image:v1")
assert ref == "ghcr.io/user/image:v1"
assert tag == "v1"

63
tests/test_completion.py Normal file
View File

@@ -0,0 +1,63 @@
"""Tests for flow.commands.completion dynamic suggestions."""
from flow.commands import completion
def test_complete_top_level_prefix():
out = completion.complete(["flow", "do"], 2)
assert "dotfiles" in out
assert "dot" in out
def test_complete_bootstrap_profiles(monkeypatch):
monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"])
out = completion.complete(["flow", "bootstrap", "show", "li"], 4)
assert out == ["linux-vm"]
def test_complete_package_install(monkeypatch):
monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"])
out = completion.complete(["flow", "package", "install", "n"], 4)
assert out == ["neovim"]
def test_complete_package_remove(monkeypatch):
monkeypatch.setattr(completion, "_list_installed_packages", lambda: ["hello", "jq"])
out = completion.complete(["flow", "package", "remove", "h"], 4)
assert out == ["hello"]
def test_complete_dotfiles_profile_value(monkeypatch):
monkeypatch.setattr(completion, "_list_dotfiles_profiles", lambda: ["work", "personal"])
out = completion.complete(["flow", "dotfiles", "link", "--profile", "w"], 5)
assert out == ["work"]
def test_complete_enter_targets(monkeypatch):
monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"])
out = completion.complete(["flow", "enter", "p"], 3)
assert out == ["personal@orb"]
def test_complete_dev_subcommands():
out = completion.complete(["flow", "dev", "c"], 3)
assert out == ["connect", "create"]
def test_complete_completion_subcommands():
out = completion.complete(["flow", "completion", "i"], 3)
assert out == ["install-zsh"]
def test_rc_snippet_is_idempotent(tmp_path):
rc_path = tmp_path / ".zshrc"
completion_dir = tmp_path / "completions"
first = completion._ensure_rc_snippet(rc_path, completion_dir)
second = completion._ensure_rc_snippet(rc_path, completion_dir)
assert first is True
assert second is False
text = rc_path.read_text()
assert text.count(completion.ZSH_RC_START) == 1
assert text.count(completion.ZSH_RC_END) == 1

70
tests/test_config.py Normal file
View File

@@ -0,0 +1,70 @@
"""Tests for flow.core.config."""
from pathlib import Path
from flow.core.config import AppConfig, FlowContext, load_config, load_manifest
def test_load_config_missing_file(tmp_path):
cfg = load_config(tmp_path / "nonexistent")
assert isinstance(cfg, AppConfig)
assert cfg.dotfiles_url == ""
assert cfg.container_registry == "registry.tomastm.com"
def test_load_config_ini(tmp_path):
config_file = tmp_path / "config"
config_file.write_text("""
[repository]
dotfiles_url=git@github.com:user/dots.git
dotfiles_branch=dev
[paths]
projects_dir=~/code
[defaults]
container_registry=my.registry.com
container_tag=v1
tmux_session=main
[targets]
personal=orb personal@orb
work=ec2 work.ec2.internal ~/.ssh/id_work
""")
cfg = load_config(config_file)
assert cfg.dotfiles_url == "git@github.com:user/dots.git"
assert cfg.dotfiles_branch == "dev"
assert cfg.projects_dir == "~/code"
assert cfg.container_registry == "my.registry.com"
assert cfg.container_tag == "v1"
assert cfg.tmux_session == "main"
assert len(cfg.targets) == 2
assert cfg.targets[0].namespace == "personal"
assert cfg.targets[0].platform == "orb"
assert cfg.targets[0].ssh_host == "personal@orb"
assert cfg.targets[1].ssh_identity == "~/.ssh/id_work"
def test_load_manifest_missing_file(tmp_path):
result = load_manifest(tmp_path / "nonexistent.yaml")
assert result == {}
def test_load_manifest_valid(tmp_path):
manifest = tmp_path / "manifest.yaml"
manifest.write_text("""
profiles:
linux-vm:
os: linux
hostname: test
""")
result = load_manifest(manifest)
assert "profiles" in result
assert result["profiles"]["linux-vm"]["os"] == "linux"
def test_load_manifest_non_dict(tmp_path):
manifest = tmp_path / "manifest.yaml"
manifest.write_text("- a\n- b\n")
result = load_manifest(manifest)
assert result == {}

95
tests/test_console.py Normal file
View File

@@ -0,0 +1,95 @@
"""Tests for flow.core.console."""
from flow.core.console import ConsoleLogger
def test_console_info(capsys):
c = ConsoleLogger()
c.info("hello")
out = capsys.readouterr().out
assert "[INFO]" in out
assert "hello" in out
def test_console_warn(capsys):
c = ConsoleLogger()
c.warn("caution")
out = capsys.readouterr().out
assert "[WARN]" in out
assert "caution" in out
def test_console_error(capsys):
c = ConsoleLogger()
c.error("bad thing")
out = capsys.readouterr().out
assert "[ERROR]" in out
assert "bad thing" in out
def test_console_success(capsys):
c = ConsoleLogger()
c.success("done")
out = capsys.readouterr().out
assert "[SUCCESS]" in out
assert "done" in out
def test_console_step_lifecycle(capsys):
c = ConsoleLogger()
c.step_start(1, 3, "Test step")
c.step_command("echo hi")
c.step_output("hi")
c.step_complete("Done")
out = capsys.readouterr().out
assert "Step 1/3" in out
assert "$ echo hi" in out
assert "Done" in out
def test_console_step_skip(capsys):
c = ConsoleLogger()
c.start_time = 0
c.step_skip("not needed")
out = capsys.readouterr().out
assert "Skipped" in out
def test_console_step_fail(capsys):
c = ConsoleLogger()
c.start_time = 0
c.step_fail("exploded")
out = capsys.readouterr().out
assert "Failed" in out
def test_console_table(capsys):
c = ConsoleLogger()
c.table(["NAME", "VALUE"], [["foo", "bar"], ["baz", "qux"]])
out = capsys.readouterr().out
assert "NAME" in out
assert "foo" in out
assert "baz" in out
def test_console_table_empty(capsys):
c = ConsoleLogger()
c.table(["NAME"], [])
out = capsys.readouterr().out
assert out == ""
def test_console_section_header(capsys):
c = ConsoleLogger()
c.section_header("Test", "sub")
out = capsys.readouterr().out
assert "TEST" in out
assert "sub" in out
def test_console_plan_header(capsys):
c = ConsoleLogger()
c.plan_header("My Plan", 5)
out = capsys.readouterr().out
assert "MY PLAN" in out
assert "5 actions" in out

67
tests/test_dotfiles.py Normal file
View File

@@ -0,0 +1,67 @@
"""Tests for flow.commands.dotfiles — link/unlink/status logic."""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flow.commands.dotfiles import _discover_packages, _walk_package
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo
@pytest.fixture
def dotfiles_tree(tmp_path):
"""Create a sample dotfiles directory structure."""
common = tmp_path / "common"
(common / "zsh").mkdir(parents=True)
(common / "zsh" / ".zshrc").write_text("# zshrc")
(common / "zsh" / ".zshenv").write_text("# zshenv")
(common / "tmux").mkdir(parents=True)
(common / "tmux" / ".tmux.conf").write_text("# tmux")
profiles = tmp_path / "profiles" / "work"
(profiles / "git").mkdir(parents=True)
(profiles / "git" / ".gitconfig").write_text("[user]\nname = Work")
return tmp_path
def test_discover_packages_common(dotfiles_tree):
packages = _discover_packages(dotfiles_tree)
assert "zsh" in packages
assert "tmux" in packages
assert "git" not in packages # git is only in profiles
def test_discover_packages_with_profile(dotfiles_tree):
packages = _discover_packages(dotfiles_tree, profile="work")
assert "zsh" in packages
assert "tmux" in packages
assert "git" in packages
def test_discover_packages_profile_overrides(dotfiles_tree):
# Add zsh to work profile
work_zsh = dotfiles_tree / "profiles" / "work" / "zsh"
work_zsh.mkdir(parents=True)
(work_zsh / ".zshrc").write_text("# work zshrc")
packages = _discover_packages(dotfiles_tree, profile="work")
# Profile should override common
assert packages["zsh"] == work_zsh
def test_walk_package(dotfiles_tree):
home = Path("/tmp/fakehome")
source = dotfiles_tree / "common" / "zsh"
pairs = list(_walk_package(source, home))
assert len(pairs) == 2
sources = {str(s.name) for s, _ in pairs}
assert ".zshrc" in sources
assert ".zshenv" in sources
targets = {str(t) for _, t in pairs}
assert str(home / ".zshrc") in targets
assert str(home / ".zshenv") in targets

View File

@@ -0,0 +1,300 @@
"""Integration tests for dotfiles tree folding behavior."""
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flow.commands.dotfiles import _discover_packages, _walk_package, run_link, run_status
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.paths import LINKED_STATE
from flow.core.platform import PlatformInfo
from flow.core.stow import LinkTree, TreeFolder
@pytest.fixture
def ctx():
"""Create a mock FlowContext."""
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=ConsoleLogger(),
)
@pytest.fixture
def dotfiles_with_nested(tmp_path):
"""Create dotfiles with nested directory structure for folding tests."""
common = tmp_path / "common"
# nvim package with nested config
nvim = common / "nvim" / ".config" / "nvim"
nvim.mkdir(parents=True)
(nvim / "init.lua").write_text("-- init")
(nvim / "lua").mkdir()
(nvim / "lua" / "config.lua").write_text("-- config")
(nvim / "lua" / "plugins.lua").write_text("-- plugins")
# zsh package with flat structure
zsh = common / "zsh"
zsh.mkdir(parents=True)
(zsh / ".zshrc").write_text("# zshrc")
(zsh / ".zshenv").write_text("# zshenv")
return tmp_path
@pytest.fixture
def home_dir(tmp_path):
"""Create a temporary home directory."""
home = tmp_path / "home"
home.mkdir()
return home
def test_tree_folding_single_package(dotfiles_with_nested, home_dir):
"""Test that a single package can be folded into directory symlink."""
# Discover nvim package
packages = _discover_packages(dotfiles_with_nested)
nvim_source = packages["nvim"]
# Build link tree
tree = LinkTree()
folder = TreeFolder(tree)
# Plan links for all nvim files
operations = []
for src, dst in _walk_package(nvim_source, home_dir):
ops = folder.plan_link(src, dst, "nvim")
operations.extend(ops)
# Execute operations
folder.execute_operations(operations, dry_run=False)
# Check that we created efficient symlinks
# In ideal case, we'd have one directory symlink instead of 3 file symlinks
nvim_config = home_dir / ".config" / "nvim"
# Verify links work
assert (nvim_config / "init.lua").exists()
assert (nvim_config / "lua" / "config.lua").exists()
def test_tree_unfolding_conflict(dotfiles_with_nested, home_dir):
"""Test that tree unfolds when second package needs same directory."""
common = dotfiles_with_nested / "common"
# Create second package that shares .config
tmux = common / "tmux" / ".config" / "tmux"
tmux.mkdir(parents=True)
(tmux / "tmux.conf").write_text("# tmux")
# First, link nvim (can fold .config/nvim)
tree = LinkTree()
folder = TreeFolder(tree)
nvim_source = common / "nvim"
for src, dst in _walk_package(nvim_source, home_dir):
ops = folder.plan_link(src, dst, "nvim")
folder.execute_operations(ops, dry_run=False)
# Now link tmux (should unfold if needed)
tmux_source = common / "tmux"
for src, dst in _walk_package(tmux_source, home_dir):
ops = folder.plan_link(src, dst, "tmux")
folder.execute_operations(ops, dry_run=False)
# Both packages should be linked
assert (home_dir / ".config" / "nvim" / "init.lua").exists()
assert (home_dir / ".config" / "tmux" / "tmux.conf").exists()
def test_state_format_with_directory_links(dotfiles_with_nested, home_dir):
"""Test that state file correctly tracks directory vs file links."""
tree = LinkTree()
# Add a directory link
tree.add_link(
home_dir / ".config" / "nvim",
dotfiles_with_nested / "common" / "nvim" / ".config" / "nvim",
"nvim",
is_dir_link=True,
)
# Add a file link
tree.add_link(
home_dir / ".zshrc",
dotfiles_with_nested / "common" / "zsh" / ".zshrc",
"zsh",
is_dir_link=False,
)
# Convert to state
state = tree.to_state()
# Verify format
assert state["version"] == 2
nvim_link = state["links"]["nvim"][str(home_dir / ".config" / "nvim")]
assert nvim_link["is_directory_link"] is True
zsh_link = state["links"]["zsh"][str(home_dir / ".zshrc")]
assert zsh_link["is_directory_link"] is False
def test_state_backward_compatibility_rejected(home_dir):
"""Old state format should be rejected (no backward compatibility)."""
old_state = {
"links": {
"zsh": {
str(home_dir / ".zshrc"): str(home_dir.parent / "dotfiles" / "zsh" / ".zshrc"),
}
}
}
with pytest.raises(RuntimeError, match="Unsupported linked state format"):
LinkTree.from_state(old_state)
def test_discover_packages_with_flow_package(tmp_path):
"""Test discovering the flow package itself from dotfiles."""
common = tmp_path / "common"
# Create flow package
flow_pkg = common / "flow" / ".config" / "flow"
flow_pkg.mkdir(parents=True)
(flow_pkg / "manifest.yaml").write_text("profiles: {}")
(flow_pkg / "config").write_text("[repository]\n")
packages = _discover_packages(tmp_path)
# Flow package should be discovered like any other
assert "flow" in packages
assert packages["flow"] == common / "flow"
def test_walk_flow_package(tmp_path):
"""Test walking the flow package structure."""
flow_pkg = tmp_path / "flow"
flow_config = flow_pkg / ".config" / "flow"
flow_config.mkdir(parents=True)
(flow_config / "manifest.yaml").write_text("profiles: {}")
(flow_config / "config").write_text("[repository]\n")
home = Path("/tmp/fakehome")
pairs = list(_walk_package(flow_pkg, home))
# Should find both files
assert len(pairs) == 2
targets = [str(t) for _, t in pairs]
assert str(home / ".config" / "flow" / "manifest.yaml") in targets
assert str(home / ".config" / "flow" / "config") in targets
def test_conflict_detection_before_execution(dotfiles_with_nested, home_dir):
"""Test that conflicts are detected before any changes are made."""
# Create existing file that conflicts
existing = home_dir / ".zshrc"
existing.parent.mkdir(parents=True, exist_ok=True)
existing.write_text("# existing zshrc")
# Try to link package that wants .zshrc
tree = LinkTree()
folder = TreeFolder(tree)
zsh_source = dotfiles_with_nested / "common" / "zsh"
operations = []
for src, dst in _walk_package(zsh_source, home_dir):
ops = folder.plan_link(src, dst, "zsh")
operations.extend(ops)
# Should detect conflict
conflicts = folder.detect_conflicts(operations)
assert len(conflicts) > 0
assert any("already exists" in c for c in conflicts)
# Original file should be unchanged
assert existing.read_text() == "# existing zshrc"
def test_profile_switching_relink(tmp_path):
"""Test switching between profiles maintains correct links."""
# Create profiles
common = tmp_path / "common"
profiles = tmp_path / "profiles"
# Common zsh
(common / "zsh").mkdir(parents=True)
(common / "zsh" / ".zshrc").write_text("# common zsh")
# Work profile override
(profiles / "work" / "zsh").mkdir(parents=True)
(profiles / "work" / "zsh" / ".zshrc").write_text("# work zsh")
# Personal profile override
(profiles / "personal" / "zsh").mkdir(parents=True)
(profiles / "personal" / "zsh" / ".zshrc").write_text("# personal zsh")
# Test that profile discovery works correctly
work_packages = _discover_packages(tmp_path, profile="work")
personal_packages = _discover_packages(tmp_path, profile="personal")
# Both should find zsh, but from different sources
assert "zsh" in work_packages
assert "zsh" in personal_packages
assert work_packages["zsh"] != personal_packages["zsh"]
def test_can_fold_empty_directory():
"""Test can_fold with empty directory."""
tree = LinkTree()
target_dir = Path("/home/user/.config/nvim")
# Empty directory - should be able to fold
assert tree.can_fold(target_dir, "nvim")
def test_can_fold_with_subdirectories():
"""Test can_fold with nested directory structure."""
tree = LinkTree()
base = Path("/home/user/.config/nvim")
# Add nested files from same package
tree.add_link(base / "init.lua", Path("/dotfiles/nvim/init.lua"), "nvim")
tree.add_link(base / "lua" / "config.lua", Path("/dotfiles/nvim/lua/config.lua"), "nvim")
tree.add_link(base / "lua" / "plugins" / "init.lua", Path("/dotfiles/nvim/lua/plugins/init.lua"), "nvim")
# Should be able to fold at base level
assert tree.can_fold(base, "nvim")
# Add file from different package
tree.add_link(base / "other.lua", Path("/dotfiles/other/other.lua"), "other")
# Now cannot fold
assert not tree.can_fold(base, "nvim")
def test_execute_operations_creates_parent_dirs(tmp_path):
"""Test that execute_operations creates necessary parent directories."""
tree = LinkTree()
folder = TreeFolder(tree)
source = tmp_path / "dotfiles" / "nvim" / ".config" / "nvim" / "init.lua"
target = tmp_path / "home" / ".config" / "nvim" / "init.lua"
# Create source
source.parent.mkdir(parents=True)
source.write_text("-- init")
# Target parent doesn't exist yet
assert not target.parent.exists()
# Plan and execute
ops = folder.plan_link(source, target, "nvim")
folder.execute_operations(ops, dry_run=False)
# Parent should be created
assert target.parent.exists()
assert target.is_symlink()

70
tests/test_paths.py Normal file
View File

@@ -0,0 +1,70 @@
"""Tests for flow.core.paths."""
from pathlib import Path
from flow.core.paths import (
CONFIG_DIR,
CONFIG_FILE,
DATA_DIR,
DOTFILES_DIR,
INSTALLED_STATE,
LINKED_STATE,
MANIFEST_FILE,
PACKAGES_DIR,
SCRATCH_DIR,
STATE_DIR,
ensure_dirs,
)
def test_config_dir_under_home():
assert ".config/devflow" in str(CONFIG_DIR)
def test_data_dir_under_home():
assert ".local/share/devflow" in str(DATA_DIR)
def test_state_dir_under_home():
assert ".local/state/devflow" in str(STATE_DIR)
def test_manifest_file_in_config_dir():
assert MANIFEST_FILE == CONFIG_DIR / "manifest.yaml"
def test_config_file_in_config_dir():
assert CONFIG_FILE == CONFIG_DIR / "config"
def test_dotfiles_dir():
assert DOTFILES_DIR == DATA_DIR / "dotfiles"
def test_packages_dir():
assert PACKAGES_DIR == DATA_DIR / "packages"
def test_scratch_dir():
assert SCRATCH_DIR == DATA_DIR / "scratch"
def test_state_files():
assert LINKED_STATE == STATE_DIR / "linked.json"
assert INSTALLED_STATE == STATE_DIR / "installed.json"
def test_ensure_dirs(tmp_path, monkeypatch):
monkeypatch.setattr("flow.core.paths.CONFIG_DIR", tmp_path / "config")
monkeypatch.setattr("flow.core.paths.DATA_DIR", tmp_path / "data")
monkeypatch.setattr("flow.core.paths.STATE_DIR", tmp_path / "state")
monkeypatch.setattr("flow.core.paths.PACKAGES_DIR", tmp_path / "data" / "packages")
monkeypatch.setattr("flow.core.paths.SCRATCH_DIR", tmp_path / "data" / "scratch")
ensure_dirs()
assert (tmp_path / "config").is_dir()
assert (tmp_path / "data").is_dir()
assert (tmp_path / "state").is_dir()
assert (tmp_path / "data" / "packages").is_dir()
assert (tmp_path / "data" / "scratch").is_dir()

32
tests/test_platform.py Normal file
View File

@@ -0,0 +1,32 @@
"""Tests for flow.core.platform."""
import platform as _platform
import pytest
from flow.core.platform import PlatformInfo, detect_container_runtime, detect_platform
def test_detect_platform_returns_platforminfo():
info = detect_platform()
assert isinstance(info, PlatformInfo)
assert info.os in ("linux", "macos")
assert info.arch in ("amd64", "arm64")
assert info.platform == f"{info.os}-{info.arch}"
def test_detect_platform_unsupported_os(monkeypatch):
monkeypatch.setattr(_platform, "system", lambda: "FreeBSD")
with pytest.raises(RuntimeError, match="Unsupported operating system"):
detect_platform()
def test_detect_platform_unsupported_arch(monkeypatch):
monkeypatch.setattr(_platform, "machine", lambda: "mips")
with pytest.raises(RuntimeError, match="Unsupported architecture"):
detect_platform()
def test_detect_container_runtime_returns_string_or_none():
result = detect_container_runtime()
assert result is None or result in ("docker", "podman")

215
tests/test_self_hosting.py Normal file
View File

@@ -0,0 +1,215 @@
"""Tests for self-hosting flow config from dotfiles repository."""
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from flow.core import paths as paths_module
from flow.core.config import load_config, load_manifest
@pytest.fixture
def mock_paths(tmp_path, monkeypatch):
"""Mock path constants for testing."""
config_dir = tmp_path / "config"
dotfiles_dir = tmp_path / "dotfiles"
config_dir.mkdir()
dotfiles_dir.mkdir()
test_paths = {
"config_dir": config_dir,
"dotfiles_dir": dotfiles_dir,
"local_config": config_dir / "config",
"local_manifest": config_dir / "manifest.yaml",
"dotfiles_config": dotfiles_dir / "flow" / ".config" / "flow" / "config",
"dotfiles_manifest": dotfiles_dir / "flow" / ".config" / "flow" / "manifest.yaml",
}
# Patch at the paths module level
monkeypatch.setattr(paths_module, "CONFIG_FILE", test_paths["local_config"])
monkeypatch.setattr(paths_module, "MANIFEST_FILE", test_paths["local_manifest"])
monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", test_paths["dotfiles_config"])
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", test_paths["dotfiles_manifest"])
return test_paths
def test_load_manifest_priority_dotfiles_first(mock_paths):
"""Test that dotfiles manifest takes priority over local."""
# Create both manifests
local_manifest = mock_paths["local_manifest"]
dotfiles_manifest = mock_paths["dotfiles_manifest"]
local_manifest.write_text("profiles:\n local:\n os: linux")
dotfiles_manifest.parent.mkdir(parents=True)
dotfiles_manifest.write_text("profiles:\n dotfiles:\n os: macos")
# Should load from dotfiles
manifest = load_manifest()
assert "dotfiles" in manifest.get("profiles", {})
assert "local" not in manifest.get("profiles", {})
def test_load_manifest_fallback_to_local(mock_paths):
"""Test fallback to local manifest when dotfiles doesn't exist."""
local_manifest = mock_paths["local_manifest"]
local_manifest.write_text("profiles:\n local:\n os: linux")
# Dotfiles manifest doesn't exist
manifest = load_manifest()
assert "local" in manifest.get("profiles", {})
def test_load_manifest_empty_when_none_exist(mock_paths):
"""Test empty dict returned when no manifests exist."""
manifest = load_manifest()
assert manifest == {}
def test_load_config_priority_dotfiles_first(mock_paths):
"""Test that dotfiles config takes priority over local."""
local_config = mock_paths["local_config"]
dotfiles_config = mock_paths["dotfiles_config"]
# Create local config
local_config.write_text(
"[repository]\n"
"dotfiles_url = https://github.com/user/dotfiles-local.git\n"
)
# Create dotfiles config
dotfiles_config.parent.mkdir(parents=True)
dotfiles_config.write_text(
"[repository]\n"
"dotfiles_url = https://github.com/user/dotfiles-from-repo.git\n"
)
# Should load from dotfiles
config = load_config()
assert "dotfiles-from-repo" in config.dotfiles_url
def test_load_config_fallback_to_local(mock_paths):
"""Test fallback to local config when dotfiles doesn't exist."""
local_config = mock_paths["local_config"]
local_config.write_text(
"[repository]\n"
"dotfiles_url = https://github.com/user/dotfiles-local.git\n"
)
# Dotfiles config doesn't exist
config = load_config()
assert "dotfiles-local" in config.dotfiles_url
def test_load_config_empty_when_none_exist(mock_paths):
"""Test default config returned when no configs exist."""
config = load_config()
assert config.dotfiles_url == ""
assert config.dotfiles_branch == "main"
def test_self_hosting_workflow(tmp_path, monkeypatch):
"""Test complete self-hosting workflow.
Simulates:
1. User has dotfiles repo with flow config
2. Flow links its own config from dotfiles
3. Flow reads from self-hosted location
"""
# Setup paths
home = tmp_path / "home"
dotfiles = tmp_path / "dotfiles"
home.mkdir()
dotfiles.mkdir()
# Create flow package in dotfiles
flow_pkg = dotfiles / "flow" / ".config" / "flow"
flow_pkg.mkdir(parents=True)
# Create manifest in dotfiles
manifest_content = {
"profiles": {
"test-env": {
"os": "linux",
"packages": {"standard": ["git", "vim"]},
}
}
}
(flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content))
# Create config in dotfiles
(flow_pkg / "config").write_text(
"[repository]\n"
"dotfiles_url = https://github.com/user/dotfiles.git\n"
)
# Mock paths to use our temp directories
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml")
monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", flow_pkg / "config")
monkeypatch.setattr(paths_module, "MANIFEST_FILE", home / ".config" / "devflow" / "manifest.yaml")
monkeypatch.setattr(paths_module, "CONFIG_FILE", home / ".config" / "devflow" / "config")
# Load config and manifest - should come from dotfiles
manifest = load_manifest()
config = load_config()
assert "test-env" in manifest.get("profiles", {})
assert "github.com/user/dotfiles.git" in config.dotfiles_url
def test_manifest_cascade_with_symlink(tmp_path, monkeypatch):
"""Test that loading works correctly when symlink is used."""
# Setup
dotfiles = tmp_path / "dotfiles"
home_config = tmp_path / "home" / ".config" / "flow"
flow_pkg = dotfiles / "flow" / ".config" / "flow"
flow_pkg.mkdir(parents=True)
home_config.mkdir(parents=True)
# Create manifest in dotfiles
manifest_content = {"profiles": {"from-dotfiles": {"os": "linux"}}}
(flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content))
# Create symlink from home config to dotfiles
manifest_link = home_config / "manifest.yaml"
manifest_link.symlink_to(flow_pkg / "manifest.yaml")
# Mock paths
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml")
monkeypatch.setattr(paths_module, "MANIFEST_FILE", manifest_link)
# Load - should work through symlink
manifest = load_manifest()
assert "from-dotfiles" in manifest.get("profiles", {})
def test_config_priority_documentation(mock_paths):
"""Document the config loading priority for users."""
# This test serves as documentation of the cascade behavior
# Priority 1: Dotfiles repo (self-hosted)
dotfiles_manifest = mock_paths["dotfiles_manifest"]
dotfiles_manifest.parent.mkdir(parents=True)
dotfiles_manifest.write_text("profiles:\n priority-1: {}")
manifest = load_manifest()
assert "priority-1" in manifest.get("profiles", {})
# If we remove dotfiles, falls back to Priority 2: Local override
dotfiles_manifest.unlink()
local_manifest = mock_paths["local_manifest"]
local_manifest.write_text("profiles:\n priority-2: {}")
manifest = load_manifest()
assert "priority-2" in manifest.get("profiles", {})
# If neither exists, Priority 3: Empty fallback
local_manifest.unlink()
manifest = load_manifest()
assert manifest == {}

310
tests/test_stow.py Normal file
View File

@@ -0,0 +1,310 @@
"""Tests for flow.core.stow — GNU Stow-style tree folding/unfolding."""
import os
from pathlib import Path
import pytest
from flow.core.stow import LinkOperation, LinkTree, TreeFolder
@pytest.fixture
def temp_home(tmp_path):
"""Create a temporary home directory."""
home = tmp_path / "home"
home.mkdir()
return home
@pytest.fixture
def temp_dotfiles(tmp_path):
"""Create a temporary dotfiles repository."""
dotfiles = tmp_path / "dotfiles"
dotfiles.mkdir()
return dotfiles
def test_linktree_add_remove():
"""Test basic LinkTree operations."""
tree = LinkTree()
source = Path("/dotfiles/zsh/.zshrc")
target = Path("/home/user/.zshrc")
tree.add_link(target, source, "zsh", is_dir_link=False)
assert target in tree.links
assert tree.links[target] == source
assert tree.packages[target] == "zsh"
assert not tree.is_directory_link(target)
tree.remove_link(target)
assert target not in tree.links
assert target not in tree.packages
def test_linktree_directory_link():
"""Test directory link tracking."""
tree = LinkTree()
source = Path("/dotfiles/nvim/.config/nvim")
target = Path("/home/user/.config/nvim")
tree.add_link(target, source, "nvim", is_dir_link=True)
assert tree.is_directory_link(target)
def test_linktree_can_fold_single_package():
"""Test can_fold with single package."""
tree = LinkTree()
target_dir = Path("/home/user/.config/nvim")
# Add files from same package
tree.add_link(target_dir / "init.lua", Path("/dotfiles/nvim/.config/nvim/init.lua"), "nvim")
tree.add_link(target_dir / "lua" / "config.lua", Path("/dotfiles/nvim/.config/nvim/lua/config.lua"), "nvim")
# Should be able to fold since all files are from same package
assert tree.can_fold(target_dir, "nvim")
def test_linktree_can_fold_multiple_packages():
"""Test can_fold with multiple packages."""
tree = LinkTree()
target_dir = Path("/home/user/.config")
# Add files from different packages
tree.add_link(target_dir / "nvim", Path("/dotfiles/nvim/.config/nvim"), "nvim", is_dir_link=True)
tree.add_link(target_dir / "tmux", Path("/dotfiles/tmux/.config/tmux"), "tmux", is_dir_link=True)
# Cannot fold .config since it has files from multiple packages
assert not tree.can_fold(target_dir, "nvim")
def test_linktree_from_state_old_format_rejected():
"""Old state format should be rejected (no backward compatibility)."""
state = {
"links": {
"zsh": {
"/home/user/.zshrc": "/dotfiles/zsh/.zshrc",
"/home/user/.zshenv": "/dotfiles/zsh/.zshenv",
}
}
}
with pytest.raises(RuntimeError, match="Unsupported linked state format"):
LinkTree.from_state(state)
def test_linktree_from_state_new_format():
"""Test loading from new state format (with is_directory_link)."""
state = {
"version": 2,
"links": {
"nvim": {
"/home/user/.config/nvim": {
"source": "/dotfiles/nvim/.config/nvim",
"is_directory_link": True,
}
}
}
}
tree = LinkTree.from_state(state)
target = Path("/home/user/.config/nvim")
assert target in tree.links
assert tree.is_directory_link(target)
assert tree.packages[target] == "nvim"
def test_linktree_to_state():
"""Test converting LinkTree to state format."""
tree = LinkTree()
tree.add_link(
Path("/home/user/.config/nvim"),
Path("/dotfiles/nvim/.config/nvim"),
"nvim",
is_dir_link=True,
)
tree.add_link(
Path("/home/user/.zshrc"),
Path("/dotfiles/zsh/.zshrc"),
"zsh",
is_dir_link=False,
)
state = tree.to_state()
assert state["version"] == 2
assert "nvim" in state["links"]
assert "zsh" in state["links"]
nvim_link = state["links"]["nvim"]["/home/user/.config/nvim"]
assert nvim_link["is_directory_link"] is True
zsh_link = state["links"]["zsh"]["/home/user/.zshrc"]
assert zsh_link["is_directory_link"] is False
def test_treefolder_plan_link_simple(temp_home, temp_dotfiles):
"""Test planning a simple file link."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
# Create source file
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
assert len(ops) == 1
assert ops[0].type == "create_symlink"
assert ops[0].source == source
assert ops[0].target == target
assert ops[0].package == "zsh"
def test_treefolder_detect_conflicts_existing_file(temp_home, temp_dotfiles):
"""Test conflict detection for existing files."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
# Create existing file
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("# existing")
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
conflicts = folder.detect_conflicts(ops)
assert len(conflicts) == 1
assert "already exists" in conflicts[0]
def test_treefolder_detect_conflicts_different_package(temp_home, temp_dotfiles):
"""Test conflict detection for links from different packages."""
tree = LinkTree()
target = temp_home / ".bashrc"
# Add existing link from different package
tree.add_link(target, Path("/dotfiles/bash/.bashrc"), "bash")
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".bashrc"
source.parent.mkdir(parents=True)
source.write_text("# bashrc")
ops = folder.plan_link(source, target, "zsh")
conflicts = folder.detect_conflicts(ops)
assert len(conflicts) == 1
assert "bash" in conflicts[0]
def test_treefolder_execute_operations_dry_run(temp_home, temp_dotfiles, capsys):
"""Test dry-run mode."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
folder.execute_operations(ops, dry_run=True)
# Check output
captured = capsys.readouterr()
assert "FILE LINK" in captured.out
assert str(target) in captured.out
# No actual symlink created
assert not target.exists()
def test_treefolder_execute_operations_create_symlink(temp_home, temp_dotfiles):
"""Test creating actual symlinks."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
folder.execute_operations(ops, dry_run=False)
# Check symlink created
assert target.is_symlink()
assert target.resolve() == source.resolve()
# Check tree updated
assert target in folder.tree.links
def test_treefolder_plan_unlink(temp_home, temp_dotfiles):
"""Test planning unlink operations."""
tree = LinkTree()
target = temp_home / ".zshrc"
source = temp_dotfiles / "zsh" / ".zshrc"
tree.add_link(target, source, "zsh")
folder = TreeFolder(tree)
ops = folder.plan_unlink(target, "zsh")
assert len(ops) == 1
assert ops[0].type == "remove"
assert ops[0].target == target
def test_treefolder_plan_unlink_directory_link(temp_home, temp_dotfiles):
"""Test planning unlink for directory symlink."""
tree = LinkTree()
target = temp_home / ".config" / "nvim"
source = temp_dotfiles / "nvim" / ".config" / "nvim"
tree.add_link(target, source, "nvim", is_dir_link=True)
folder = TreeFolder(tree)
ops = folder.plan_unlink(target, "nvim")
# Should remove the directory link
assert len(ops) >= 1
assert ops[-1].type == "remove"
assert ops[-1].is_directory_link
def test_linkoperation_str():
"""Test LinkOperation string representation."""
op1 = LinkOperation(
type="create_symlink",
source=Path("/src"),
target=Path("/dst"),
package="test",
is_directory_link=False,
)
assert "FILE LINK" in str(op1)
op2 = LinkOperation(
type="create_symlink",
source=Path("/src"),
target=Path("/dst"),
package="test",
is_directory_link=True,
)
assert "DIR LINK" in str(op2)
op3 = LinkOperation(
type="unfold",
source=Path("/src"),
target=Path("/dst"),
package="test",
)
assert "UNFOLD" in str(op3)

52
tests/test_variables.py Normal file
View File

@@ -0,0 +1,52 @@
"""Tests for flow.core.variables."""
from flow.core.variables import substitute, substitute_template
def test_substitute_dollar():
result = substitute("hello $NAME", {"NAME": "world"})
assert result == "hello world"
def test_substitute_braces():
result = substitute("hello ${NAME}", {"NAME": "world"})
assert result == "hello world"
def test_substitute_multiple():
result = substitute("$A and ${B}", {"A": "1", "B": "2"})
assert result == "1 and 2"
def test_substitute_home():
result = substitute("dir=$HOME", {})
assert "$HOME" not in result
def test_substitute_user():
import os
result = substitute("u=$USER", {})
assert result == f"u={os.getenv('USER', '')}"
def test_substitute_non_string():
assert substitute(123, {}) == 123
def test_substitute_template_basic():
result = substitute_template("nvim-{{os}}-{{arch}}.tar.gz", {"os": "linux", "arch": "x86_64"})
assert result == "nvim-linux-x86_64.tar.gz"
def test_substitute_template_missing_key():
result = substitute_template("{{missing}}", {})
assert result == "{{missing}}"
def test_substitute_template_non_string():
assert substitute_template(42, {}) == 42
def test_substitute_template_no_placeholders():
result = substitute_template("plain text", {"os": "linux"})
assert result == "plain text"