Source code for jamb.matrix.formats.markdown

"""Markdown traceability matrix output."""

from jamb.core.models import FullChainMatrix, MatrixMetadata, TestRecord


def _escape_markdown(text: str) -> str:
    """Escape markdown special characters for table cells.

    Escapes characters that could break markdown table rendering:
    - | (table separator)
    - * (bold/italic)
    - ` (code)
    - [ and ] (links)
    - \\ (escape character itself)
    - newlines (converted to spaces)

    Args:
        text: The plain text to escape.

    Returns:
        The string with special characters escaped.
    """
    # Order matters: escape backslash first
    text = text.replace("\\", "\\\\")
    text = text.replace("|", "\\|")
    text = text.replace("*", "\\*")
    text = text.replace("`", "\\`")
    text = text.replace("[", "\\[")
    text = text.replace("]", "\\]")
    text = text.replace("\n", " ")
    return text


def _truncate_for_table(text: str, max_len: int = 200) -> str:
    """Escape and truncate text for markdown table with ellipsis indicator.

    Escapes markdown special characters and truncates with '...'
    if the result exceeds max_len.

    Args:
        text: The text to escape and truncate.
        max_len: Maximum length before truncation (default: 200).

    Returns:
        The escaped and possibly truncated string.
    """
    escaped = _escape_markdown(text)
    if len(escaped) > max_len:
        return escaped[: max_len - 3] + "..."
    return escaped


[docs] def render_test_records_markdown( records: list[TestRecord], metadata: MatrixMetadata | None = None, ) -> str: """Render test records as Markdown. Args: records: List of TestRecord objects to render. metadata: Optional matrix metadata for IEC 62304 5.7.5 compliance. Returns: A string containing a Markdown document with a metadata section, summary section, and a pipe-delimited table of all test records. """ # Calculate stats total = len(records) passed = sum(1 for r in records if r.outcome == "passed") failed = sum(1 for r in records if r.outcome == "failed") skipped = sum(1 for r in records if r.outcome == "skipped") error = sum(1 for r in records if r.outcome == "error") pass_rate = f"{100 * passed / total:.1f}%" if total else "0%" lines = [ "# Test Records", "", ] # Add metadata section if provided if metadata: lines.extend( [ "## Metadata", "", f"- **Software Version:** {metadata.software_version or 'Unknown'}", f"- **Tester:** {metadata.tester_id}", f"- **Date:** {metadata.execution_timestamp or 'Unknown'}", ] ) if metadata.environment: env = metadata.environment env_str = ( f"{env.os_name} {env.os_version}, Python {env.python_version}, " f"{env.platform}, {env.processor}, {env.hostname}, " f"{env.cpu_count} cores" ) lines.append(f"- **Environment:** {env_str}") if env.test_tools: tools = [f"{name} {ver}" for name, ver in sorted(env.test_tools.items())] lines.append(f"- **Test Tools:** {', '.join(tools)}") lines.append("") lines.extend( [ "## Summary", "", f"- **Total Tests:** {total}", f"- **Passed:** {passed}", f"- **Failed:** {failed}", f"- **Skipped:** {skipped}", f"- **Error:** {error}", f"- **Pass Rate:** {pass_rate}", "", "## Test Records", "", ] ) # Build header row with explicit list for maintainability headers = [ "Test Case", "Test Name", "Outcome", "Requirements", "Test Actions", "Expected Results", "Actual Results", "Notes", "Timestamp", ] lines.append("| " + " | ".join(headers) + " |") lines.append("| " + " | ".join(["---"] * len(headers)) + " |") for rec in records: test_name = _escape_markdown(rec.test_name) outcome = _escape_markdown(rec.outcome) requirements_str = _escape_markdown(", ".join(rec.requirements)) or "-" test_actions_str = "; ".join(_truncate_for_table(a) for a in rec.test_actions) if rec.test_actions else "-" expected_results_str = ( "; ".join(_truncate_for_table(r) for r in rec.expected_results) if rec.expected_results else "-" ) actual_results_str = ( "; ".join(_truncate_for_table(r) for r in rec.actual_results) if rec.actual_results else "-" ) notes_str = "; ".join(_truncate_for_table(n) for n in rec.notes) if rec.notes else "-" timestamp = _escape_markdown(rec.execution_timestamp or "-") lines.append( f"| {rec.test_id} | `{test_name}` " f"| {outcome} | {requirements_str} | {test_actions_str} " f"| {expected_results_str} | {actual_results_str} " f"| {notes_str} | {timestamp} |" ) return "\n".join(lines)
[docs] def render_full_chain_markdown( matrices: list[FullChainMatrix], tc_mapping: dict[str, str] | None = None, ) -> str: """Render full chain trace matrices as Markdown. Args: matrices: List of FullChainMatrix objects to render. tc_mapping: Optional mapping from test nodeid to TC ID for display. Returns: A string containing Markdown with all matrices. """ tc_mapping = tc_mapping or {} lines = ["# Traceability Matrix", ""] # Overall summary total = sum(m.summary.get("total", 0) for m in matrices) passed = sum(m.summary.get("passed", 0) for m in matrices) failed = sum(m.summary.get("failed", 0) for m in matrices) not_covered = sum(m.summary.get("not_covered", 0) for m in matrices) lines.extend( [ "## Summary", "", f"- **Total Items:** {total}", f"- **Passed:** {passed}", f"- **Failed:** {failed}", f"- **Not Covered:** {not_covered}", "", ] ) for matrix in matrices: # Build header headers = [] if matrix.include_ancestors: headers.append("Traces To") headers.extend(matrix.document_hierarchy) for col_config in matrix.column_configs: headers.append(col_config.header) headers.extend(["Tests", "Status"]) lines.append("| " + " | ".join(headers) + " |") lines.append("| " + " | ".join(["---"] * len(headers)) + " |") # Build rows for row in matrix.rows: cells = [] # Traces To column if matrix.include_ancestors: ancestors = _escape_markdown(", ".join(row.ancestor_uids)) cells.append(ancestors if ancestors else "-") # Document columns for prefix in matrix.document_hierarchy: item = row.chain.get(prefix) if item: # Bold UID and header, unbold text if item.header: uid_header = f"{item.uid}: {item.header}" cell_text = f"**{uid_header}** - {item.text}" else: uid_part = f"{item.uid}:" cell_text = f"**{uid_part}** {item.text}" cells.append(_truncate_for_table(cell_text)) else: cells.append("-") # Extra columns for col_config in matrix.column_configs: value = row.extra_columns.get(col_config.key, col_config.default) cells.append(_escape_markdown(value)) # Tests column tests = [] for test in row.descendant_tests: test_name = test.test_nodeid.split("::")[-1] outcome = test.test_outcome or "unknown" escaped_name = _escape_markdown(test_name) escaped_outcome = _escape_markdown(outcome) tc_id = tc_mapping.get(test.test_nodeid, "") tc_prefix = f"{tc_id}: " if tc_id else "" tests.append(f"`{tc_prefix}{escaped_name}` [{escaped_outcome}]") cells.append(", ".join(tests) if tests else "-") # Status column cells.append(row.rollup_status) lines.append("| " + " | ".join(cells) + " |") lines.append("") return "\n".join(lines)