tests/test_render.py (6058 bytes)
1 from __future__ import annotations 2 3 import json 4 import shutil 5 import tomllib 6 from pathlib import Path 7 8 import pytest 9 from hypothesis import example, given 10 from hypothesis import strategies as st 11 12 from ai_config_render import BUILTIN_TARGETS, main, render_instructions, render_json, render_toml 13 14 EXAMPLE = Path(__file__).parent.parent / "examples" / "basic" 15 16 17 @pytest.fixture 18 def env(tmp_path: Path) -> tuple[Path, Path, Path]: 19 """A source copy, a fake home, and a machine file that points both into tmp_path.""" 20 home, source = tmp_path / "home", tmp_path / "src" 21 shutil.copytree(EXAMPLE, source) 22 cfg = (source / "aicr.toml").read_text(encoding="utf-8") 23 extra = "".join(f"[targets.{t}]\n" for t in BUILTIN_TARGETS if f"[targets.{t}]" not in cfg) 24 (source / "aicr.toml").write_text(cfg + extra, encoding="utf-8") 25 machine = tmp_path / "machine.toml" 26 machine.write_text( 27 f'name = "test"\nstate = {json.dumps(str(tmp_path / "state.json"))}\n[vars]\nhome = {json.dumps(home.as_posix())}\n', 28 encoding="utf-8", 29 ) 30 return source, home, machine 31 32 33 def run(cmd: str, source: Path, machine: Path) -> int: 34 return main([cmd, "--source", str(source), "--machine", str(machine)]) 35 36 37 def test_renders_every_builtin_format(env: tuple[Path, Path, Path]) -> None: 38 source, home, machine = env 39 assert run("render", source, machine) == 0 40 for spec in BUILTIN_TARGETS.values(): 41 if "instructions" in spec: 42 text = Path(spec["instructions"].replace("${home}", str(home))).read_text(encoding="utf-8") 43 assert "<!-- aicr:style -->\n## Style" in text and "<!-- /aicr:testing -->" in text 44 p = Path(spec["mcp"]["path"].replace("${home}", str(home))) 45 if spec["mcp"]["format"] == "toml": 46 srv = tomllib.loads(p.read_text(encoding="utf-8"))["mcp_servers"]["filesystem"] 47 else: 48 srv = json.loads(p.read_text(encoding="utf-8"))["mcpServers"]["filesystem"] 49 assert srv["command"] == "npx" and srv["args"][-1] == f"{home.as_posix()}/projects" 50 for d in spec.get("skills", []): 51 assert (Path(d.replace("${home}", str(home))) / "commit-message" / "SKILL.md").is_file() 52 copilot = json.loads((home / ".copilot" / "mcp-config.json").read_text(encoding="utf-8")) 53 assert copilot["mcpServers"]["filesystem"]["tools"] == ["*"] 54 55 56 def test_idempotent_and_check(env: tuple[Path, Path, Path]) -> None: 57 source, _, machine = env 58 assert run("check", source, machine) == 1 59 run("render", source, machine) 60 assert run("check", source, machine) == 0 61 assert run("render", source, machine) == 0 62 assert run("check", source, machine) == 0 63 64 65 def test_preserves_user_content_and_drops_removed_entries(env: tuple[Path, Path, Path]) -> None: 66 source, home, machine = env 67 claude_md, codex = home / ".claude" / "CLAUDE.md", home / ".codex" / "config.toml" 68 claude_md.parent.mkdir(parents=True) 69 claude_md.write_text("# Mine\nkeep me\n", encoding="utf-8") 70 codex.parent.mkdir(parents=True) 71 codex.write_text('model = "x"\n\n[mcp_servers.mine]\ncommand = "a"\n', encoding="utf-8") 72 run("render", source, machine) 73 cfg = (source / "aicr.toml").read_text(encoding="utf-8") 74 (source / "aicr.toml").write_text( 75 cfg.replace("[rules.testing]\n", "").replace("[mcp.filesystem]", "[mcp.gone]"), encoding="utf-8" 76 ) 77 run("render", source, machine) 78 text = claude_md.read_text(encoding="utf-8") 79 assert text.startswith("# Mine\nkeep me\n") and "aicr:style" in text and "aicr:testing" not in text 80 data = tomllib.loads(codex.read_text(encoding="utf-8")) 81 assert data["model"] == "x" and set(data["mcp_servers"]) == {"mine", "gone"} 82 assert list(claude_md.parent.glob("CLAUDE.md.aicr-bak")) 83 84 85 def test_list_targets(capsys: pytest.CaptureFixture[str], env: tuple[Path, Path, Path]) -> None: 86 source, _, machine = env 87 assert run("list-targets", source, machine) == 0 88 assert "* codex" in capsys.readouterr().out 89 90 91 text_st = st.text(st.characters(exclude_categories=["Cs"]), max_size=200).filter(lambda s: "<!--" not in s) 92 name_st = st.from_regex(r"[a-z][a-z0-9_-]{0,8}", fullmatch=True) 93 94 95 @given(text_st, st.dictionaries(name_st, text_st.filter(lambda s: s.strip() != ""), max_size=4), text_st) 96 def test_instruction_merge_round_trip(user: str, blocks: dict[str, str], new_body: str) -> None: 97 once = render_instructions(user, blocks, set()) 98 assert render_instructions(once, blocks, set()) == once # idempotent 99 for name, body in blocks.items(): 100 assert f"<!-- aicr:{name} -->\n{body.rstrip()}\n<!-- /aicr:{name} -->\n" in once 101 assert render_instructions(once, {}, set(blocks)).rstrip("\n") == user.rstrip("\n") # removing restores user text 102 103 104 json_val = st.recursive( 105 st.none() | st.booleans() | st.integers() | st.text(max_size=10), lambda c: st.lists(c, max_size=3), max_leaves=5 106 ) 107 108 109 @given(st.dictionaries(name_st, json_val, max_size=3), st.dictionaries(name_st, st.just({"command": "c"}), max_size=3)) 110 def test_json_merge_keeps_user_keys(user: dict[str, object], servers: dict[str, dict[str, object]]) -> None: 111 out = json.loads(render_json(json.dumps({"other": user}), servers, set(servers), "mcpServers")) 112 assert out["other"] == user and out["mcpServers"] == servers 113 assert json.loads(render_json(json.dumps(out), {}, set(servers), "mcpServers")) == {"other": user, "mcpServers": {}} 114 115 116 @given( 117 st.dictionaries( 118 name_st.filter(lambda n: n != "user"), 119 st.dictionaries(name_st, st.text(max_size=10) | st.integers(), max_size=3), 120 max_size=3, 121 ) 122 ) 123 @example({"a": {"a": chr(0x7F)}}) # regression: a raw DEL made invalid TOML 124 def test_toml_round_trip(servers: dict[str, dict[str, object]]) -> None: 125 out = render_toml('top = 1\n\n[mcp_servers.user]\ncommand = "u"\n', servers, set(servers)) 126 data = tomllib.loads(out) 127 assert data["top"] == 1 and data["mcp_servers"].pop("user") == {"command": "u"} 128 assert data["mcp_servers"] == servers 129 assert render_toml(out, servers, set(servers)) == out