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"