generated from coulomb/repo-seed
238 lines
7.5 KiB
Python
238 lines
7.5 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from markitect_tool.cli import main
|
|
from markitect_tool.document_function import (
|
|
DocumentFunctionDescriptor,
|
|
DocumentFunctionParameter,
|
|
DocumentFunctionRegistry,
|
|
DocumentValue,
|
|
MAX_FUNCTION_PIPELINE_DEPTH,
|
|
coerce_document_value,
|
|
default_document_function_registry,
|
|
document_value_to_json,
|
|
format_document_value,
|
|
parse_document_function_calls,
|
|
render_document_functions,
|
|
validate_document_functions,
|
|
)
|
|
from markitect_tool.extension import ProcessingContext
|
|
|
|
|
|
def test_parse_inline_and_fenced_function_calls():
|
|
text = """# Demo
|
|
|
|
Inline {{mkt:text.upper "draft"}}.
|
|
|
|
```mkt-function md.heading level=3
|
|
Decision
|
|
```
|
|
"""
|
|
|
|
calls = parse_document_function_calls(text)
|
|
|
|
assert [call.function_id for call in calls] == ["text.upper", "md.heading"]
|
|
assert calls[0].args == ["draft"]
|
|
assert calls[1].kwargs == {"level": 3}
|
|
assert calls[1].body.strip() == "Decision"
|
|
|
|
|
|
def test_render_document_functions_expands_inline_and_fenced_calls():
|
|
text = """# Demo
|
|
|
|
Inline {{mkt:text.upper "draft"}}.
|
|
|
|
```mkt-function md.heading level=3
|
|
Decision
|
|
```
|
|
"""
|
|
|
|
result = render_document_functions(text)
|
|
|
|
assert result.valid
|
|
assert "Inline DRAFT." in result.content
|
|
assert "### Decision" in result.content
|
|
assert len(result.calls) == 2
|
|
assert result.provenance[0].operation == "document_function.text.upper"
|
|
assert result.calls[0].value.kind == "string"
|
|
assert result.calls[1].value.kind == "markdown"
|
|
|
|
|
|
def test_document_values_coerce_and_map_to_markdown():
|
|
table = coerce_document_value(
|
|
[{"name": "Ada", "role": "Architect"}, {"name": "Grace", "role": "Reviewer"}],
|
|
declared_kind="table",
|
|
)
|
|
|
|
assert document_value_to_json(table)["kind"] == "table"
|
|
assert format_document_value(DocumentValue(kind="boolean", value=True), inline=True) == "true"
|
|
assert "| name | role |" in format_document_value(table, inline=False)
|
|
assert "| Ada | Architect |" in format_document_value(table, inline=False)
|
|
|
|
|
|
def test_pipeline_passes_previous_output_to_next_function():
|
|
result = render_document_functions('{{mkt:text.upper "draft" | text.replace DRAFT Final}}')
|
|
|
|
assert result.valid
|
|
assert result.content == "Final"
|
|
|
|
|
|
def test_pipeline_separator_inside_quotes_is_literal():
|
|
result = render_document_functions('{{mkt:text.replace "a|b" "|" "/"}}')
|
|
|
|
assert result.valid
|
|
assert result.content == "a/b"
|
|
|
|
|
|
def test_pipeline_depth_limit_reports_syntax_error():
|
|
expression = " | ".join(["text.trim value"] * (MAX_FUNCTION_PIPELINE_DEPTH + 1))
|
|
|
|
try:
|
|
parse_document_function_calls("{{mkt:" + expression + "}}")
|
|
except Exception as exc:
|
|
assert "maximum depth" in str(exc)
|
|
else:
|
|
raise AssertionError("Expected pipeline depth diagnostic")
|
|
|
|
|
|
def test_context_variables_can_be_used_in_function_arguments():
|
|
context = ProcessingContext(variables={"title": "Architecture Decision"})
|
|
|
|
result = render_document_functions("{{mkt:md.heading ${title} level=2}}", context=context)
|
|
|
|
assert result.content == "## Architecture Decision"
|
|
|
|
|
|
def test_dynamic_context_values_render_through_typed_mapper():
|
|
context = ProcessingContext(variables={"items": ["alpha", "beta"]})
|
|
|
|
result = render_document_functions("{{mkt:data.get items}}", context=context)
|
|
|
|
assert result.valid
|
|
assert result.content == "alpha, beta"
|
|
assert result.calls[0].value.kind == "list"
|
|
|
|
|
|
def test_validate_document_functions_reports_forbidden_calls():
|
|
result = validate_document_functions("{{mkt:text.upper draft}}", forbidden=["text.upper"])
|
|
|
|
assert not result.valid
|
|
assert result.diagnostics[0].code == "function.forbidden"
|
|
|
|
|
|
def test_validate_document_functions_reports_argument_errors():
|
|
result = validate_document_functions("{{mkt:text.upper draft unexpected=value}}")
|
|
|
|
assert not result.valid
|
|
assert result.diagnostics[0].code == "function.arguments"
|
|
|
|
|
|
def test_registry_can_expose_custom_function_without_core_rewrite():
|
|
registry = DocumentFunctionRegistry()
|
|
registry.register(
|
|
DocumentFunctionDescriptor(
|
|
id="demo.wrap",
|
|
summary="Wrap text.",
|
|
parameters=[DocumentFunctionParameter("value")],
|
|
implementation=lambda value: f"[{value}]",
|
|
)
|
|
)
|
|
|
|
result = render_document_functions("{{mkt:demo.wrap ok}}", registry=registry)
|
|
|
|
assert result.valid
|
|
assert result.content == "[ok]"
|
|
|
|
|
|
def test_descriptor_output_type_mismatch_is_diagnostic():
|
|
registry = DocumentFunctionRegistry()
|
|
registry.register(
|
|
DocumentFunctionDescriptor(
|
|
id="demo.count",
|
|
summary="Return a count.",
|
|
output_type="number",
|
|
implementation=lambda: "not-a-number",
|
|
)
|
|
)
|
|
|
|
result = render_document_functions("{{mkt:demo.count}}", registry=registry)
|
|
|
|
assert not result.valid
|
|
assert result.content == "{{mkt:demo.count}}"
|
|
assert result.diagnostics[0].code == "function.output_type_mismatch"
|
|
|
|
|
|
def test_reference_values_require_provenance():
|
|
registry = DocumentFunctionRegistry()
|
|
registry.register(
|
|
DocumentFunctionDescriptor(
|
|
id="demo.reference",
|
|
summary="Return a reference.",
|
|
output_type="reference",
|
|
implementation=lambda: DocumentValue(kind="reference", value="std:clause.md#payment"),
|
|
)
|
|
)
|
|
|
|
result = render_document_functions("{{mkt:demo.reference}}", registry=registry)
|
|
|
|
assert not result.valid
|
|
assert result.diagnostics[0].code == "function.provenance_missing"
|
|
|
|
|
|
def test_unknown_function_is_left_in_place_with_diagnostic():
|
|
result = render_document_functions("{{mkt:nope.missing value}}")
|
|
|
|
assert not result.valid
|
|
assert result.content == "{{mkt:nope.missing value}}"
|
|
assert result.diagnostics[0].code == "function.unknown"
|
|
|
|
|
|
def test_mkt_function_list_outputs_builtin_catalog():
|
|
result = CliRunner().invoke(main, ["function", "list", "--format", "json"])
|
|
data = json.loads(result.output)
|
|
|
|
assert result.exit_code == 0
|
|
ids = {function["id"] for function in data["functions"]}
|
|
assert {"text.upper", "md.heading", "md.codeblock"} <= ids
|
|
|
|
|
|
def test_mkt_function_render_outputs_expanded_markdown(tmp_path: Path):
|
|
file = tmp_path / "functions.md"
|
|
file.write_text("# Demo\n\n{{mkt:md.bold Important}}\n", encoding="utf-8")
|
|
|
|
result = CliRunner().invoke(main, ["function", "render", str(file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert "**Important**" in result.output
|
|
|
|
|
|
def test_mkt_function_render_json_includes_typed_values(tmp_path: Path):
|
|
file = tmp_path / "functions.md"
|
|
file.write_text("{{mkt:text.upper draft}}\n", encoding="utf-8")
|
|
|
|
result = CliRunner().invoke(main, ["function", "render", str(file), "--format", "json"])
|
|
data = json.loads(result.output)
|
|
|
|
assert result.exit_code == 0
|
|
assert data["calls"][0]["value"] == {"kind": "string", "value": "DRAFT"}
|
|
|
|
|
|
def test_mkt_function_check_can_restrict_allowed_functions(tmp_path: Path):
|
|
file = tmp_path / "functions.md"
|
|
file.write_text("{{mkt:text.upper draft}}\n", encoding="utf-8")
|
|
|
|
result = CliRunner().invoke(main, ["function", "check", str(file), "--allow", "md.heading"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "function.not_allowed" in result.output
|
|
|
|
|
|
def test_default_registry_serializes_without_implementations():
|
|
data = default_document_function_registry().to_dict()
|
|
|
|
assert data["count"] >= 1
|
|
assert "implementation" not in data["functions"][0]
|
|
assert {function["id"]: function["output_type"] for function in data["functions"]}["data.get"] == "dynamic"
|