mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Tests for ``finalize`` — declared-action-tree finalization + cache write.
|
|
|
|
Covers:
|
|
- ``sort_tree_in_place`` pure tree mutation (TOPIC-first, alphabetical-by-api_name)
|
|
- ``main`` WORK_DIR / DATA_DIR / CACHE_DIR orchestration
|
|
|
|
The pure-function tests build small dict trees inline. ``main`` tests
|
|
construct a minimal WORK_DIR layout under ``tmp_path`` so the orchestration
|
|
runs end-to-end without any external metadata fixtures.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import unittest
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
from . import _bootstrap # noqa: F401 — sys.path setup
|
|
|
|
import finalize # type: ignore
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# sort_tree_in_place — pure tree mutation
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class SortTreeInPlaceTests(unittest.TestCase):
|
|
"""Per docstring: TOPIC nodes first, then non-topic plannerActions.
|
|
Within each tier, sorted by kind then api_name (case-insensitive).
|
|
Each TOPIC's own children: alphabetical by api_name (case-insensitive).
|
|
FLOW children are NOT sorted.
|
|
"""
|
|
|
|
def test_empty_tree_is_noop(self):
|
|
root = {}
|
|
finalize.sort_tree_in_place(root)
|
|
self.assertEqual(root, {})
|
|
|
|
def test_root_with_no_children_is_noop(self):
|
|
root = {"children": []}
|
|
finalize.sort_tree_in_place(root)
|
|
self.assertEqual(root["children"], [])
|
|
|
|
def test_non_dict_root_is_safe(self):
|
|
# Non-dict input shouldn't blow up.
|
|
finalize.sort_tree_in_place(None) # type: ignore[arg-type]
|
|
finalize.sort_tree_in_place([]) # type: ignore[arg-type]
|
|
|
|
def test_topics_come_before_non_topics_at_root(self):
|
|
root = {"children": [
|
|
{"kind": "STANDARD_ACTION", "api_name": "actA"},
|
|
{"kind": "TOPIC", "api_name": "topicZ"},
|
|
{"kind": "APEX", "api_name": "apexA"},
|
|
{"kind": "TOPIC", "api_name": "topicA"},
|
|
]}
|
|
finalize.sort_tree_in_place(root)
|
|
kinds = [c["kind"] for c in root["children"]]
|
|
# Two TOPICs first, then non-topics
|
|
self.assertEqual(kinds[:2], ["TOPIC", "TOPIC"])
|
|
self.assertNotIn("TOPIC", kinds[2:])
|
|
|
|
def test_topics_sort_alphabetical_case_insensitive(self):
|
|
root = {"children": [
|
|
{"kind": "TOPIC", "api_name": "Zeta"},
|
|
{"kind": "TOPIC", "api_name": "alpha"},
|
|
{"kind": "TOPIC", "api_name": "Beta"},
|
|
]}
|
|
finalize.sort_tree_in_place(root)
|
|
names = [c["api_name"] for c in root["children"]]
|
|
self.assertEqual(names, ["alpha", "Beta", "Zeta"])
|
|
|
|
def test_non_topics_sort_by_kind_then_name(self):
|
|
root = {"children": [
|
|
{"kind": "STANDARD_ACTION", "api_name": "z_act"},
|
|
{"kind": "APEX", "api_name": "z_apex"},
|
|
{"kind": "STANDARD_ACTION", "api_name": "a_act"},
|
|
{"kind": "APEX", "api_name": "a_apex"},
|
|
]}
|
|
finalize.sort_tree_in_place(root)
|
|
# Tier 1 sorted by (kind, api_name): APEX rows before STANDARD_ACTION
|
|
kinds = [c["kind"] for c in root["children"]]
|
|
self.assertEqual(kinds, ["APEX", "APEX", "STANDARD_ACTION", "STANDARD_ACTION"])
|
|
names_apex = [c["api_name"] for c in root["children"][:2]]
|
|
self.assertEqual(names_apex, ["a_apex", "z_apex"])
|
|
|
|
def test_topic_children_sorted_alphabetical_case_insensitive(self):
|
|
root = {"children": [{
|
|
"kind": "TOPIC",
|
|
"api_name": "T1",
|
|
"children": [
|
|
{"kind": "STANDARD_ACTION", "api_name": "Zebra"},
|
|
{"kind": "STANDARD_ACTION", "api_name": "apple"},
|
|
{"kind": "STANDARD_ACTION", "api_name": "Banana"},
|
|
],
|
|
}]}
|
|
finalize.sort_tree_in_place(root)
|
|
names = [c["api_name"] for c in root["children"][0]["children"]]
|
|
self.assertEqual(names, ["apple", "Banana", "Zebra"])
|
|
|
|
def test_topic_with_no_children_is_safe(self):
|
|
root = {"children": [{"kind": "TOPIC", "api_name": "T1"}]}
|
|
finalize.sort_tree_in_place(root)
|
|
self.assertEqual(root["children"][0]["api_name"], "T1")
|
|
|
|
def test_missing_api_name_treated_as_empty(self):
|
|
# Should sort to the front under casefold of empty string.
|
|
root = {"children": [
|
|
{"kind": "TOPIC", "api_name": "Beta"},
|
|
{"kind": "TOPIC"}, # no api_name
|
|
]}
|
|
finalize.sort_tree_in_place(root)
|
|
names = [c.get("api_name", "") for c in root["children"]]
|
|
self.assertEqual(names[0], "") # empty/missing comes first
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# main — orchestration via env vars + WORK_DIR
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _build_workdir(tmp: Path, *, planner_name: str = "MyPlanner",
|
|
pending: dict | None = None,
|
|
unresolved: list | None = None) -> tuple[Path, Path, Path]:
|
|
"""Construct a minimal WORK_DIR + DATA_DIR + CACHE_DIR triple.
|
|
|
|
Returns (work_dir, data_dir, cache_dir). Caller passes these as env
|
|
vars before invoking finalize.main().
|
|
"""
|
|
work_dir = tmp / "work"
|
|
data_dir = tmp / "data" / "ALPHA0000000000" / "MyAgent__v1"
|
|
cache_dir = tmp / "cache" / "ALPHA0000000000" / "MyAgent__v1"
|
|
work_dir.mkdir(parents=True)
|
|
|
|
tree = {
|
|
"agent": {"api_name": "MyAgent", "version": "v1"},
|
|
"node_count": 1,
|
|
"depth": 1,
|
|
"_schema_version": "3.1",
|
|
"_kind_counts": {"TOPIC": 1},
|
|
"_pending_fetches": pending or {},
|
|
"_unresolved": unresolved or [],
|
|
"root": {"children": [{"kind": "TOPIC", "api_name": "Greetings"}]},
|
|
}
|
|
(work_dir / "declared_action_tree.json").write_text(json.dumps(tree))
|
|
return work_dir, data_dir, cache_dir
|
|
|
|
|
|
class MainTests(unittest.TestCase):
|
|
|
|
def _run_main(self, work_dir: Path, data_dir: Path, cache_dir: Path,
|
|
*, planner_name: str = "MyPlanner") -> int:
|
|
old = dict(os.environ)
|
|
os.environ.update({
|
|
"WORK_DIR": str(work_dir),
|
|
"DATA_DIR": str(data_dir),
|
|
"CACHE_DIR": str(cache_dir),
|
|
"AGENT_API_NAME": "MyAgent",
|
|
"AGENT_VERSION": "v1",
|
|
"PLANNER_NAME": planner_name,
|
|
})
|
|
try:
|
|
return finalize.main()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(old)
|
|
|
|
def test_main_writes_tree_and_manifest_and_returns_zero(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(tmp)
|
|
rc = self._run_main(work_dir, data_dir, cache_dir)
|
|
self.assertEqual(rc, 0)
|
|
tree_file = data_dir / "MyAgent_v1_metadata_tree.json"
|
|
self.assertTrue(tree_file.exists())
|
|
self.assertTrue((data_dir / "last_built_at.txt").exists())
|
|
manifest = json.loads((cache_dir / "manifest.json").read_text())
|
|
self.assertEqual(manifest["agent"]["api_name"], "MyAgent")
|
|
self.assertEqual(manifest["partial"], False)
|
|
self.assertEqual(manifest["unresolved_count"], 0)
|
|
|
|
def test_main_marks_partial_when_planner_name_empty(self):
|
|
# Empty PLANNER_NAME → _partial=True, _partial_reason="no-planner".
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(tmp)
|
|
rc = self._run_main(work_dir, data_dir, cache_dir, planner_name="")
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads(
|
|
(data_dir / "MyAgent_v1_metadata_tree.json").read_text()
|
|
)
|
|
self.assertTrue(tree["_partial"])
|
|
self.assertEqual(tree["_partial_reason"], "no-planner")
|
|
|
|
def test_main_marks_partial_when_unresolved_present(self):
|
|
# Planner OK, _pending empty, but _unresolved non-empty
|
|
# → _partial=True, _partial_reason="unresolved-refs".
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(
|
|
tmp, unresolved=[{"reason": "404"}]
|
|
)
|
|
rc = self._run_main(work_dir, data_dir, cache_dir)
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads(
|
|
(data_dir / "MyAgent_v1_metadata_tree.json").read_text()
|
|
)
|
|
self.assertTrue(tree["_partial"])
|
|
self.assertEqual(tree["_partial_reason"], "unresolved-refs")
|
|
|
|
def test_main_marks_partial_when_pending_present(self):
|
|
# Planner OK, _unresolved empty, but _pending non-empty
|
|
# → _partial=True, _partial_reason="pending-refs".
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(
|
|
tmp, pending={"flows": ["FlowA"]}
|
|
)
|
|
rc = self._run_main(work_dir, data_dir, cache_dir)
|
|
self.assertEqual(rc, 0)
|
|
tree = json.loads(
|
|
(data_dir / "MyAgent_v1_metadata_tree.json").read_text()
|
|
)
|
|
self.assertTrue(tree["_partial"])
|
|
self.assertEqual(tree["_partial_reason"], "pending-refs")
|
|
|
|
def test_main_returns_one_when_env_missing(self):
|
|
old = dict(os.environ)
|
|
# Wipe required envs. Set only one.
|
|
for k in ("WORK_DIR", "DATA_DIR", "CACHE_DIR",
|
|
"AGENT_API_NAME", "AGENT_VERSION"):
|
|
os.environ.pop(k, None)
|
|
try:
|
|
rc = finalize.main()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(old)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_main_returns_one_when_tree_json_missing(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir = tmp / "work"
|
|
data_dir = tmp / "data"
|
|
cache_dir = tmp / "cache"
|
|
work_dir.mkdir()
|
|
# No declared_action_tree.json — finalize should bail.
|
|
rc = self._run_main(work_dir, data_dir, cache_dir)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_main_strips_visited_from_durable_artifact(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(tmp)
|
|
tree_path = work_dir / "declared_action_tree.json"
|
|
tree = json.loads(tree_path.read_text())
|
|
tree["_visited"] = {"some": "internal-state"}
|
|
tree_path.write_text(json.dumps(tree))
|
|
rc = self._run_main(work_dir, data_dir, cache_dir)
|
|
self.assertEqual(rc, 0)
|
|
written = json.loads(
|
|
(data_dir / "MyAgent_v1_metadata_tree.json").read_text()
|
|
)
|
|
self.assertNotIn("_visited", written)
|
|
|
|
def test_main_seeds_gitignore_on_data_and_cache_roots(self):
|
|
with TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
work_dir, data_dir, cache_dir = _build_workdir(tmp)
|
|
self._run_main(work_dir, data_dir, cache_dir)
|
|
# data_dir.parent.parent == tmp/data; cache_dir.parent.parent == tmp/cache
|
|
self.assertTrue((data_dir.parent.parent / ".gitignore").exists())
|
|
self.assertTrue((cache_dir.parent.parent / ".gitignore").exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|