451 lines
18 KiB
Python
451 lines
18 KiB
Python
"""Unit tests for build.py.
|
|
|
|
Run from this directory with:
|
|
python3 -m unittest test_build
|
|
|
|
These tests guard the parts of the renderer a bad JSON file could break
|
|
silently: the raw-patch to Shiki-marker conversion, per-language comment
|
|
tokens, block rendering and its mutual-exclusion rules, HTML escaping,
|
|
GitHub file links, canvas layout, edge validation, and the required-field
|
|
checks. build.py uses only the standard library, and so do these tests.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
|
|
import build # noqa: E402
|
|
|
|
|
|
def minimal_change():
|
|
"""A fresh, valid change dict. layout() mutates it, so never share one."""
|
|
return {
|
|
"title": "T",
|
|
"file": "a/b.go",
|
|
"why": "does a thing",
|
|
"blocks": [{"lang": "go", "code": "x := 1"}],
|
|
}
|
|
|
|
|
|
def minimal_data():
|
|
return {
|
|
"pr": {"number": 1, "title": "P", "url": "https://h/pr/1",
|
|
"repo": "o/r", "base": "master", "head": "feat"},
|
|
"why": {"problem": "p", "what": ["w"]},
|
|
"changes": [minimal_change()],
|
|
"risk": {"score": 2, "reasons": ["r"]},
|
|
"review": {},
|
|
}
|
|
|
|
|
|
class TestPatchToMarked(unittest.TestCase):
|
|
def test_headers_dropped_context_kept_indices_recorded(self):
|
|
patch = ("diff --git a/f b/f\nindex 1..2 100644\n--- a/f\n+++ b/f\n"
|
|
"@@ -1,3 +1,3 @@ func F() {\n ctx\n-old\n+new\n more")
|
|
code, added, removed = build.patch_to_marked(patch)
|
|
self.assertEqual(code, "ctx\nold\nnew\nmore")
|
|
self.assertEqual(added, [3])
|
|
self.assertEqual(removed, [2])
|
|
for h in ("diff --git", "index ", "@@", "--- ", "+++ ", "[!code"):
|
|
self.assertNotIn(h, code)
|
|
|
|
def test_blank_line_preserved(self):
|
|
self.assertEqual(build.patch_to_marked(" a\n\n b"), ("a\n\nb", [], []))
|
|
|
|
def test_marker_never_inlined(self):
|
|
code, added, removed = build.patch_to_marked("+x\n-y")
|
|
self.assertEqual(code, "x\ny")
|
|
self.assertEqual((added, removed), ([1], [2]))
|
|
|
|
def test_no_newline_marker_dropped(self):
|
|
code, added, removed = build.patch_to_marked(
|
|
"@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file")
|
|
self.assertEqual(code, "old\nnew")
|
|
self.assertEqual((added, removed), ([2], [1]))
|
|
|
|
def test_removed_line_starting_with_double_dash_is_kept(self):
|
|
# Inside a hunk, a removed "-- note" line becomes "--- note" in the
|
|
# patch (leading '-' diff marker). It must be kept as a removed line,
|
|
# not dropped as a header. The diff marker is stripped, leaving "-- note".
|
|
patch = "@@ -1,1 +1,0 @@\n--- note"
|
|
code, added, removed = build.patch_to_marked(patch)
|
|
self.assertEqual(code, "-- note")
|
|
self.assertEqual((added, removed), ([], [1]))
|
|
|
|
def test_added_line_starting_with_double_plus_is_kept(self):
|
|
# Inside a hunk, an added "++i" line becomes "+++i" in the patch. It
|
|
# must be kept as an added line, not dropped as a header.
|
|
patch = "@@ -1,0 +1,1 @@\n+++i"
|
|
code, added, removed = build.patch_to_marked(patch)
|
|
self.assertEqual(code, "++i")
|
|
self.assertEqual((added, removed), ([1], []))
|
|
|
|
def test_file_headers_before_first_hunk_still_dropped(self):
|
|
patch = ("diff --git a/f b/f\nindex 1..2 100644\n--- a/f\n+++ b/f\n"
|
|
"@@ -1,1 +1,1 @@\n-old\n+new")
|
|
code, added, removed = build.patch_to_marked(patch)
|
|
self.assertEqual(code, "old\nnew")
|
|
self.assertEqual((added, removed), ([2], [1]))
|
|
|
|
def test_comment_token_inside_string_is_not_treated_as_marker(self):
|
|
# A Rust raw string that contains "//" must stay intact: the marker is
|
|
# never inlined, so nothing inside the code can look like a diff marker.
|
|
patch = '@@ -1,0 +1,1 @@\n+ r#"{"url":"/x"}, // note"#'
|
|
code, added, removed = build.patch_to_marked(patch)
|
|
self.assertEqual(code, ' r#"{"url":"/x"}, // note"#')
|
|
self.assertEqual((added, removed), ([1], []))
|
|
self.assertNotIn("[!code", code)
|
|
|
|
|
|
class TestCommentTokens(unittest.TestCase):
|
|
def test_known_languages(self):
|
|
self.assertEqual(build.COMMENT_TOKENS["python"], "#")
|
|
self.assertEqual(build.COMMENT_TOKENS["bash"], "#")
|
|
self.assertEqual(build.COMMENT_TOKENS["sql"], "--")
|
|
self.assertEqual(build.COMMENT_TOKENS["clojure"], ";")
|
|
|
|
def test_unknown_language_falls_back_to_slashes(self):
|
|
self.assertEqual(build.COMMENT_TOKENS.get("go", "//"), "//")
|
|
|
|
|
|
class TestRenderBlock(unittest.TestCase):
|
|
def test_plain_excerpt_escapes_and_has_no_diff(self):
|
|
html = build.render_block({"lang": "go", "code": "a < b & c"}, "c")
|
|
self.assertIn("a < b & c", html)
|
|
self.assertNotIn('data-diff="true"', html)
|
|
|
|
def test_hand_marked_diff_strips_markers_and_records_indices(self):
|
|
html = build.render_block(
|
|
{"lang": "go", "code": "old // [!code --]\nnew // [!code ++]",
|
|
"diff": True}, "c")
|
|
self.assertIn('data-diff="true"', html)
|
|
# The markers are stripped from the code and turned into index attrs.
|
|
self.assertNotIn("[!code", html)
|
|
self.assertIn('data-added="2"', html)
|
|
self.assertIn('data-removed="1"', html)
|
|
# The bare code text (marker removed) is still present, escaped.
|
|
self.assertIn("old\nnew", html)
|
|
|
|
def test_diff_with_no_markers_marks_all_lines_added(self):
|
|
html = build.render_block(
|
|
{"lang": "python", "code": "def f():\n\n return 1",
|
|
"diff": True}, "c")
|
|
self.assertIn('data-diff="true"', html)
|
|
# Every line is added, including the blank line 2, so the green tint
|
|
# stays continuous across the excerpt.
|
|
self.assertIn('data-added="1,2,3"', html)
|
|
self.assertNotIn("data-removed", html)
|
|
self.assertNotIn("[!code", html)
|
|
|
|
def test_all_added_indices_includes_blank_lines(self):
|
|
self.assertEqual(build.all_added_indices("a\n\nb"), [1, 2, 3])
|
|
|
|
def test_parse_hand_marked_strips_any_comment_token(self):
|
|
code, added, removed = build.parse_hand_marked(
|
|
"keep\nx = 1 # [!code ++]\ndrop -- [!code --]")
|
|
self.assertEqual(code, "keep\nx = 1\ndrop")
|
|
self.assertEqual((added, removed), ([2], [3]))
|
|
|
|
def test_markdown_block(self):
|
|
html = build.render_block(
|
|
{"render": "markdown", "code": "| a |"}, "c")
|
|
self.assertIn('data-render="markdown"', html)
|
|
|
|
def test_patch_block_marks_diff_by_index(self):
|
|
html = build.render_block(
|
|
{"lang": "bash", "patch": "+echo hi"}, "c")
|
|
self.assertIn('data-diff="true"', html)
|
|
self.assertIn('data-added="1"', html)
|
|
self.assertNotIn("[!code", html)
|
|
|
|
def test_patch_and_code_together_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.render_block(
|
|
{"lang": "go", "patch": "+x", "code": "x"}, "c")
|
|
|
|
def test_patch_with_diff_flag_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.render_block(
|
|
{"lang": "go", "patch": "+x", "diff": True}, "c")
|
|
|
|
def test_missing_lang_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.render_block({"code": "x"}, "c")
|
|
|
|
def test_block_risk_emits_data_attr(self):
|
|
html = build.render_block(
|
|
{"lang": "go", "code": "x := 1",
|
|
"risk": {"score": 7,
|
|
"lines": [{"match": "x", "severity": "high",
|
|
"note": "n"}]}},
|
|
"c")
|
|
self.assertIn("data-risk=", html)
|
|
self.assertIn(""match": "x"", html)
|
|
self.assertIn(""severity": "high"", html)
|
|
|
|
def test_block_risk_without_lines_emits_no_data_attr(self):
|
|
html = build.render_block(
|
|
{"lang": "go", "code": "x := 1", "risk": {"score": 4}}, "c")
|
|
self.assertNotIn("data-risk=", html)
|
|
|
|
|
|
class TestBlockRiskChip(unittest.TestCase):
|
|
def test_chip_carries_score_and_reason(self):
|
|
chip = build.block_risk_chip({"score": 7, "reason": "spins"})
|
|
self.assertIn('data-score="7"', chip)
|
|
self.assertIn('title="spins"', chip)
|
|
self.assertIn('block-risk-score">7<', chip)
|
|
|
|
def test_chip_empty_without_score(self):
|
|
self.assertEqual(build.block_risk_chip({"reason": "x"}), "")
|
|
|
|
|
|
class TestValidateBlockRisk(unittest.TestCase):
|
|
def test_valid_risk_passes(self):
|
|
risk = {"score": 5,
|
|
"lines": [{"match": "y", "severity": "medium"}]}
|
|
self.assertIs(build.validate_block_risk(risk, "c"), risk)
|
|
|
|
def test_score_out_of_range_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate_block_risk({"score": 11}, "c")
|
|
|
|
def test_line_missing_match_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate_block_risk(
|
|
{"lines": [{"severity": "high"}]}, "c")
|
|
|
|
def test_bad_severity_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate_block_risk(
|
|
{"lines": [{"match": "y", "severity": "critical"}]}, "c")
|
|
|
|
def test_validate_catches_bad_block_risk(self):
|
|
data = minimal_data()
|
|
data["changes"][0]["blocks"][0]["risk"] = {"score": 99}
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_missing_pr_repo_fails(self):
|
|
data = minimal_data()
|
|
del data["pr"]["repo"]
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_why_what_string_fails(self):
|
|
data = minimal_data()
|
|
data["why"]["what"] = "one string not a list"
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_risk_reasons_string_fails(self):
|
|
data = minimal_data()
|
|
data["risk"]["reasons"] = "one string not a list"
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_top_level_risk_score_non_numeric_fails(self):
|
|
data = minimal_data()
|
|
data["risk"]["score"] = "high"
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_top_level_risk_score_out_of_range_fails(self):
|
|
data = minimal_data()
|
|
data["risk"]["score"] = 11
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
|
|
class TestEscaping(unittest.TestCase):
|
|
def test_esc_leaves_quotes_but_escapes_angles(self):
|
|
self.assertEqual(build.esc('a<b>"c'), 'a<b>"c')
|
|
|
|
def test_esc_attr_escapes_quotes(self):
|
|
self.assertIn(""", build.esc_attr('a"b'))
|
|
|
|
def test_none_becomes_empty(self):
|
|
self.assertEqual(build.esc(None), "")
|
|
|
|
|
|
class TestFileUrl(unittest.TestCase):
|
|
def test_explicit_url_wins(self):
|
|
pr = {"url": "https://h/pr/1"}
|
|
self.assertEqual(
|
|
build.file_url({"file": "x", "file_url": "https://o"}, pr),
|
|
"https://o")
|
|
|
|
def test_default_is_sha_anchor(self):
|
|
url = build.file_url({"file": "a/b.go"}, {"url": "https://h/pr/1"})
|
|
self.assertTrue(url.startswith("https://h/pr/1/files#diff-"))
|
|
self.assertEqual(len(url.split("diff-")[1]), 64)
|
|
|
|
def test_executable_scheme_is_rejected(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.file_url(
|
|
{"file": "x", "file_url": "javascript:alert(1)"},
|
|
{"url": "https://h/pr/1"})
|
|
|
|
def test_non_http_scheme_is_rejected(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.file_url(
|
|
{"file": "x", "file_url": "data:text/html,payload"},
|
|
{"url": "https://h/pr/1"})
|
|
|
|
|
|
class TestLayout(unittest.TestCase):
|
|
def test_ids_assigned_and_two_column_grid(self):
|
|
changes = [minimal_change() for _ in range(3)]
|
|
boxes = build.layout(changes)
|
|
self.assertEqual([c["id"] for c in changes], ["n1", "n2", "n3"])
|
|
self.assertEqual(boxes, [])
|
|
# 3 changes -> 2 columns, so n3 wraps to the second row, column 0.
|
|
self.assertEqual(changes[2]["_x"], build.ORIGIN_X)
|
|
self.assertEqual(changes[2]["_y"], build.ORIGIN_Y + build.ROW_STRIDE)
|
|
|
|
def test_more_than_four_changes_uses_three_columns(self):
|
|
changes = [minimal_change() for _ in range(5)]
|
|
build.layout(changes)
|
|
self.assertEqual(changes[3]["_x"], build.ORIGIN_X + 0 * build.COL_STRIDE)
|
|
self.assertEqual(changes[3]["_y"], build.ORIGIN_Y + build.ROW_STRIDE)
|
|
|
|
def test_groups_make_one_column_each_with_a_box(self):
|
|
changes = [minimal_change(), minimal_change(), minimal_change()]
|
|
changes[0]["group"] = "A"
|
|
changes[1]["group"] = "A"
|
|
changes[2]["group"] = "B"
|
|
boxes = build.layout(changes)
|
|
self.assertEqual(len(boxes), 2)
|
|
# Group A is column 0, its two nodes stack in rows 0 and 1.
|
|
self.assertEqual(changes[0]["_x"], build.ORIGIN_X)
|
|
self.assertEqual(changes[1]["_y"], build.ORIGIN_Y + build.ROW_STRIDE)
|
|
# Group B is column 1.
|
|
self.assertEqual(changes[2]["_x"], build.ORIGIN_X + build.COL_STRIDE)
|
|
|
|
|
|
class TestRenderEdges(unittest.TestCase):
|
|
def test_valid_edge_renders(self):
|
|
html = build.render_edges(
|
|
[{"from": "n1", "to": "n2", "label": "calls"}], {"n1", "n2"})
|
|
self.assertIn('data-from="n1"', html)
|
|
self.assertIn('data-label="calls"', html)
|
|
|
|
def test_unknown_from_node_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.render_edges([{"from": "nX", "to": "n1"}], {"n1"})
|
|
|
|
def test_unknown_to_node_fails(self):
|
|
with self.assertRaises(build.BuildError):
|
|
build.render_edges([{"from": "n1", "to": "nX"}], {"n1"})
|
|
|
|
|
|
class TestValidate(unittest.TestCase):
|
|
def test_minimal_data_passes(self):
|
|
build.validate(minimal_data()) # no raise
|
|
|
|
def test_missing_pr_field_fails(self):
|
|
data = minimal_data()
|
|
del data["pr"]["url"]
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_empty_changes_fails(self):
|
|
data = minimal_data()
|
|
data["changes"] = []
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_change_missing_blocks_fails(self):
|
|
data = minimal_data()
|
|
data["changes"][0]["blocks"] = []
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
def test_missing_review_fails(self):
|
|
data = minimal_data()
|
|
del data["review"]
|
|
with self.assertRaises(build.BuildError):
|
|
build.validate(data)
|
|
|
|
|
|
class TestBuild(unittest.TestCase):
|
|
def test_full_build_escapes_and_fills_placeholders(self):
|
|
data = minimal_data()
|
|
data["pr"]["title"] = "Fix <a> & <b>"
|
|
data["changes"][0]["blocks"] = [{"lang": "go", "code": "if a < b {}"}]
|
|
out = build.build(data)
|
|
# No placeholder survives.
|
|
for token in ("{{PR_TITLE}}", "{{PR_URL}}", "{{PR_FILES_URL}}",
|
|
"{{PR_NUMBER}}", "{{REPO}}", "{{RISK_SCORE}}",
|
|
"{{CONTENT}}"):
|
|
self.assertNotIn(token, out)
|
|
# Title and code are escaped in the output.
|
|
self.assertIn("Fix <a> & <b>", out)
|
|
self.assertIn("if a < b {}", out)
|
|
|
|
def test_topbar_risk_badge_carries_score(self):
|
|
data = minimal_data()
|
|
data["risk"]["score"] = 8
|
|
out = build.build(data)
|
|
# The topbar badge and its score span both get the filled score.
|
|
self.assertIn('id="topbar-risk" href="#risk" data-score="8"', out)
|
|
self.assertIn('<span id="topbar-risk-score">8</span>', out)
|
|
|
|
def test_block_risk_chip_and_lines_render(self):
|
|
data = minimal_data()
|
|
data["changes"][0]["blocks"] = [
|
|
{"lang": "go", "code": "close := true",
|
|
"risk": {"score": 7, "reason": "terminal flag",
|
|
"lines": [{"match": "close", "severity": "high",
|
|
"note": "wrong value stalls stream"}]}}]
|
|
out = build.build(data)
|
|
self.assertIn('class="block-risk-chip" data-score="7"', out)
|
|
self.assertIn("data-risk=", out)
|
|
self.assertIn("wrong value stalls stream", out)
|
|
|
|
def test_dual_views_render_each_change(self):
|
|
data = minimal_data()
|
|
data["changes"][0]["title"] = "UniqueChangeTitle"
|
|
out = build.build(data)
|
|
# Rendered in both the canvas node and the list article.
|
|
self.assertIn(
|
|
'<span class="cnode-title">UniqueChangeTitle</span>', out)
|
|
self.assertIn(
|
|
'<h3 class="font-semibold text-brand-soft mb-1">'
|
|
'UniqueChangeTitle</h3>', out)
|
|
|
|
def test_shell_matches_kandev_landing_visual_language(self):
|
|
out = build.build(minimal_data())
|
|
self.assertIn("--wt-primary: #4f46e5", out)
|
|
self.assertIn("--wt-bg: #0d0d10", out)
|
|
self.assertIn("font-family: 'Figtree'", out)
|
|
self.assertIn("font-family: 'Geist Mono'", out)
|
|
self.assertIn('class="wt-topbar sticky', out)
|
|
self.assertIn('class="wt-brand-mark"', out)
|
|
|
|
def test_runtime_cdn_dependencies_use_exact_versions(self):
|
|
out = build.build(minimal_data())
|
|
for url in (
|
|
"https://cdn.tailwindcss.com/3.4.17",
|
|
"https://cdn.jsdelivr.net/npm/mermaid@11.17.0/dist/mermaid.esm.min.mjs",
|
|
"https://cdn.jsdelivr.net/npm/marked@12.0.2/+esm",
|
|
"https://cdn.jsdelivr.net/npm/dompurify@3.4.14/+esm",
|
|
"https://cdn.jsdelivr.net/npm/shiki@3.23.0/+esm",
|
|
):
|
|
self.assertIn(url, out)
|
|
self.assertNotIn('src="https://cdn.tailwindcss.com"', out)
|
|
|
|
def test_shell_has_mobile_section_navigation(self):
|
|
out = build.build(minimal_data())
|
|
self.assertIn('id="mobile-nav"', out)
|
|
self.assertIn('id="mobile-nav-architecture"', out)
|
|
self.assertIn('id="mobile-nav-data"', out)
|
|
self.assertIn("@media (max-width: 640px)", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|