test_garden_layer.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import os
  2. import pytest
  3. import requests
  4. from typing import Any, Dict
  5. from garden_layer import GardenLayer
  6. from garden_layer.config import CLONE_OF, GRAPH, KEROSENE_ROOT, MCP_URL
  7. CYCLE_2026_3 = "http://world.eu.org/example1#ProductionCycle_21d96b6c-179"
  8. FIXTURE_FILES = ["ganja_test_fixture.ttl"]
  9. def pytest_report_header(config):
  10. return "Garden layer tests: requires virtuoso_mcp running at GardenLayer.MCP_URL"
  11. @pytest.fixture
  12. def garden_layer():
  13. layer = GardenLayer()
  14. try:
  15. layer.traverse(KEROSENE_ROOT, CLONE_OF, direction="incoming", limit=1)
  16. except requests.RequestException as exc:
  17. pytest.skip(f"Cannot reach MCP server: {exc}")
  18. if os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() == "true":
  19. try:
  20. layer.load_examples(files=FIXTURE_FILES, graph=GRAPH)
  21. except Exception as exc:
  22. pytest.skip(f"Cannot load garden fixtures: {exc}")
  23. result = layer.traverse(KEROSENE_ROOT, CLONE_OF, direction="incoming", limit=1)
  24. bindings = result.get("results", {}).get("bindings", [])
  25. if not bindings:
  26. pytest.skip("No garden fixture data available; set MCP_ALLOW_EXAMPLE_LOAD=true")
  27. return layer
  28. def test_traverse_clone_tree(garden_layer):
  29. """Walk `cloneOf` incoming edges and ensure the root has clones to inspect."""
  30. result = garden_layer.traverse(KEROSENE_ROOT, CLONE_OF, direction="incoming", limit=5)
  31. assert isinstance(result, dict)
  32. bindings = result.get("results", {}).get("bindings", [])
  33. assert isinstance(bindings, list)
  34. assert bindings, "Expected at least one clone binding"
  35. print(f"Traverse of {KEROSENE_ROOT} returned {len(bindings)} clone candidates.")
  36. def test_describe_subject(garden_layer):
  37. """Describe the root plant and surface the top predicates the garden helpers can reuse."""
  38. summary = garden_layer.describe_subject(KEROSENE_ROOT, limit=5)
  39. assert isinstance(summary, dict)
  40. bindings = summary.get("results", {}).get("bindings", [])
  41. assert bindings, "Expected describe_subject to return bindings"
  42. triples = [f"{b['predicate']['value']} -> {b['object'].get('value', '<literal>')}" for b in bindings]
  43. print("describe_subject returned:")
  44. for triple in triples:
  45. print(" ", triple)
  46. def test_path_traverse_lineage(garden_layer):
  47. """Use the path helper to follow `cloneOf` and observe the lineage step."""
  48. path = garden_layer.path_traverse(KEROSENE_ROOT, [CLONE_OF], direction="incoming", limit=5)
  49. assert isinstance(path, dict)
  50. bindings = path.get("result", {}).get("results", {}).get("bindings", [])
  51. assert isinstance(bindings, list)
  52. assert bindings, "Path traverse should find at least one step"
  53. print(f"Property path {CLONE_OF} produced {len(bindings)} step bindings.")
  54. def test_property_usage_statistics(garden_layer):
  55. """Summarize how the `cloneOf` property is used so future helpers can rely on frequency data."""
  56. stats = garden_layer.property_usage_statistics(CLONE_OF, examples_limit=3)
  57. assert isinstance(stats, dict)
  58. count_bindings = stats.get("count", {}).get("results", {}).get("bindings", [])
  59. assert count_bindings, "Expected usage count bindings"
  60. usage_count = count_bindings[0].get("usageCount", {}).get("value")
  61. example_bindings = stats.get("examples", {}).get("results", {}).get("bindings", [])
  62. assert isinstance(example_bindings, list)
  63. print(f"cloneOf usage count: {usage_count}")
  64. print("example bindings:")
  65. for binding in example_bindings:
  66. subject = binding.get("subjectLabel", {}).get("value") or binding.get("subject", {}).get("value")
  67. object_ = binding.get("objectLabel", {}).get("value") or binding.get("object", {}).get("value")
  68. print(f" - {subject} -> {object_}")
  69. def test_batch_insert(garden_layer):
  70. """Batch-insert a TTL snippet and verify the query shape"""
  71. ttl = '<http://world.eu.org/example1#batch_test_subject> <http://www.w3.org/2000/01/rdf-schema#label> "garden batch" .'
  72. result = garden_layer.batch_insert(ttl=ttl, graph=GRAPH)
  73. assert isinstance(result, dict)
  74. assert "query" in result
  75. assert "INSERT DATA" in result.get("query", "").upper()
  76. print("batch_insert generated:")
  77. print(result.get("query", ""))
  78. def call_mcp_tool(tool_name: str, payload: Dict[str, Any]) -> Any:
  79. # Some ontology/traversal calls can be a bit slow depending on Virtuoso load.
  80. response = requests.post(MCP_URL, json={"tool": tool_name, "input": payload}, timeout=20)
  81. if response.status_code >= 400:
  82. raise AssertionError(
  83. f"{tool_name} failed with {response.status_code}: {response.text}"
  84. )
  85. response.raise_for_status()
  86. body = response.json()
  87. assert body.get("status") == "ok", f"{tool_name} failed: {body.get('detail') or 'unknown reason'}"
  88. return body["result"]
  89. def test_mcp_garden_describe_subject():
  90. result = call_mcp_tool(
  91. "garden_describe_subject",
  92. {"subject_uri": KEROSENE_ROOT, "limit": 1},
  93. )
  94. bindings = result.get("results", {}).get("bindings", [])
  95. assert bindings, "garden_describe_subject should return bindings"
  96. print("garden_describe_subject returned", len(bindings), "bindings via /mcp")
  97. def test_mcp_garden_property_usage_statistics():
  98. result = call_mcp_tool(
  99. "garden_property_usage_statistics",
  100. {"property_uri": CLONE_OF, "examples_limit": 1},
  101. )
  102. count_bindings = result.get("count", {}).get("results", {}).get("bindings", [])
  103. assert count_bindings, "Expected usage count bindings from the garden tool"
  104. examples = result.get("examples", {}).get("results", {}).get("bindings", [])
  105. assert isinstance(examples, list)
  106. print("garden_property_usage_statistics sample bindings:", len(examples))
  107. def test_mcp_garden_cycle_plants():
  108. result = call_mcp_tool(
  109. "garden_cycle_plants",
  110. {"cycle_uri": CYCLE_2026_3, "limit": 5},
  111. )
  112. bindings = result.get("results", {}).get("bindings", [])
  113. assert bindings, "Expected at least one plant binding from the cycle"
  114. print("garden_cycle_plants returned", len(bindings), "plants for cycle", CYCLE_2026_3)
  115. for binding in bindings:
  116. plant_uri = binding.get("plant", {}).get("value")
  117. assert plant_uri, "Each binding should include a plant URI"
  118. def test_mcp_garden_cycle_plants_latest_when_cycle_uri_missing():
  119. """MCP compliance: cycle_uri should be optional and default to latest cycle."""
  120. result = call_mcp_tool(
  121. "garden_cycle_plants",
  122. {"limit": 3},
  123. )
  124. bindings = result.get("results", {}).get("bindings", [])
  125. assert bindings, "Expected plant bindings from the latest cycle when cycle_uri is omitted"
  126. def test_mcp_garden_cycle_list_detailed_has_strain_info():
  127. """Ensure the detailed cycle list includes strain labels/descriptions and clone siblings."""
  128. result = call_mcp_tool(
  129. "garden_cycle_list_detailed",
  130. {"limit": 10},
  131. )
  132. bindings = result.get("results", {}).get("bindings", [])
  133. assert bindings, "Expected at least one binding from garden_cycle_list_detailed"
  134. # Check that at least one binding has strainDesc or cloneSiblings populated.
  135. any_strain_desc = any('strainDesc' in b and b.get('strainDesc', {}).get('value') for b in bindings)
  136. any_siblings = any('cloneSiblings' in b and b.get('cloneSiblings', {}).get('value') for b in bindings)
  137. assert any_strain_desc or any_siblings, "Expected strainDesc and/or cloneSiblings to be present"
  138. def test_mcp_input_schema_announced_for_garden_tools():
  139. """OpenClaw MCP compliance: garden_* tools should expose inputSchema via tools/list."""
  140. response = requests.post(
  141. MCP_URL,
  142. json={"jsonrpc": "2.0", "id": 999, "method": "tools/list", "params": {}},
  143. timeout=10,
  144. )
  145. response.raise_for_status()
  146. body = response.json()
  147. assert body.get("result"), f"Expected tools/list result, got: {body}"
  148. tools = body["result"].get("tools") or []
  149. tool_by_name = {t.get("name"): t for t in tools if isinstance(t, dict)}
  150. # At least validate a couple of the “new” tools.
  151. for name in [
  152. "garden_clone_to",
  153. "garden_cycle_plants",
  154. "garden_cycle_list_detailed",
  155. ]:
  156. assert name in tool_by_name, f"Missing tool {name} in tools/list"
  157. input_schema = tool_by_name[name].get("inputSchema")
  158. assert isinstance(input_schema, dict), f"{name}.inputSchema missing/invalid"
  159. # Specifically: garden_cycle_plants should allow missing cycle_uri.
  160. cycle_plants_schema = tool_by_name["garden_cycle_plants"].get("inputSchema", {})
  161. props = cycle_plants_schema.get("properties", {})
  162. assert "cycle_uri" in props, "garden_cycle_plants inputSchema must define cycle_uri"
  163. def test_load_examples_fixture(garden_layer):
  164. if os.getenv("MCP_ALLOW_EXAMPLE_LOAD", "false").lower() != "true":
  165. pytest.skip("MCP_ALLOW_EXAMPLE_LOAD must be true to load example fixtures")
  166. result = garden_layer.load_examples(files=["test_fixture.ttl"], graph=GRAPH)
  167. loaded = result.get("loaded", [])
  168. assert any(item.get("file") == "test_fixture.ttl" for item in loaded), "Expected fixture file to be loaded"