Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-16 08:16:23

0001 #!/usr/bin/env python3
0002 """Self-tests for the public API surface tooling.
0003 
0004 Guards two things against silent breakage:
0005   * the pure classification/extraction logic (fast, no external deps), and
0006   * the end-to-end pipeline over real Doxygen output, using committed fixture
0007     headers with a known set of API differences -- this also catches a Doxygen
0008     upgrade quietly changing its XML.
0009 
0010 Run directly (``python3 CI/public_api/test_public_api_surface.py``): all ``test_*``
0011 functions run; a non-zero exit means a failure. The e2e test is skipped when
0012 ``doxygen`` is not on PATH. Also collectable by pytest.
0013 """
0014 
0015 from __future__ import annotations
0016 
0017 import json
0018 import os
0019 import shutil
0020 import subprocess
0021 import sys
0022 import tempfile
0023 import xml.etree.ElementTree as ET
0024 from pathlib import Path
0025 
0026 HERE = Path(__file__).resolve().parent
0027 sys.path.insert(0, str(HERE))
0028 
0029 import public_api_surface as pas  # noqa: E402
0030 import public_api_diff as pad  # noqa: E402
0031 
0032 # --- pure unit tests -------------------------------------------------------
0033 
0034 
0035 def test_callable_forms_expands_defaulted_args():
0036     md = ET.fromstring(
0037         '<memberdef kind="function" const="no">'
0038         "<type>void</type><name>foo</name>"
0039         "<param><type>int</type><declname>x</declname></param>"
0040         "<param><type>double</type><declname>y</declname><defval>1.0</defval></param>"
0041         "</memberdef>"
0042     )
0043     forms = pas.callable_forms(md, "Acts::foo")
0044     assert "Acts::foo(int)" in forms, forms  # shorter call still valid
0045     assert "Acts::foo(int, double)" in forms, forms
0046 
0047 
0048 def test_callable_forms_const_qualifier():
0049     md = ET.fromstring(
0050         '<memberdef kind="function" const="yes">'
0051         "<type>int</type><name>size</name></memberdef>"
0052     )
0053     assert "Acts::C::size() const" in pas.callable_forms(md, "Acts::C::size")
0054 
0055 
0056 def test_module_of_components():
0057     assert pas.module_of("/w/Core/include/Acts/Surfaces/Plane.hpp") == "Core/Surfaces"
0058     assert (
0059         pas.module_of("/w/Plugins/Json/include/ActsPlugins/Json/X.hpp") == "Plugin:Json"
0060     )
0061     assert pas.module_of("/w/Fatras/include/ActsFatras/Y.hpp") == "Fatras"
0062     assert pas.module_of("/w/Alignment/include/ActsAlignment/Z.hpp") == "Alignment"
0063 
0064 
0065 def test_sanitize_strips_bidi_and_zero_width_chars():
0066     # U+202E right-to-left override, U+200B zero-width space: neither is
0067     # visible whitespace, so a crafted C++20 Unicode identifier could smuggle
0068     # either into a report without this stripping them.
0069     assert pas._sanitize("Acts::Foo‮​") == "Acts::Foo"
0070 
0071 
0072 def test_name_and_norm_type_sanitize_extracted_text():
0073     md = ET.fromstring(
0074         "<memberdef><name>size‮</name>" "<type>Acts::Bar​*</type></memberdef>"
0075     )
0076     assert pas._name(md, "name") == "size"
0077     assert pas._norm_type(md.find("type")) == "Acts::Bar*"
0078 
0079 
0080 def test_classify_sanitizes_untrusted_json_input():
0081     # public_api_diff.py is the trusted (base-branch) script, but its input
0082     # JSON comes from an unprivileged job a PR fully controls -- it could
0083     # hand-craft this JSON directly, bidi/zero-width characters included.
0084     base = {"symbols": [], "callables": {}, "fields": {}}
0085     head = {"symbols": ["type Acts::New‮​"], "callables": {}, "fields": {}}
0086     c = pad.classify(base, head)
0087     assert c["added_names"] == ["type Acts::New"]
0088 
0089 
0090 def test_classify_additions_and_breaking():
0091     base = {
0092         "symbols": ["type Acts::Old", "type Acts::Keep"],
0093         "callables": {
0094             "Acts::foo(int)": "void",
0095             "Acts::gone(double)": "void",
0096             "Acts::baz()": "int",
0097         },
0098         "fields": {"Acts::S::a": "double", "Acts::S::gone": "int"},
0099     }
0100     head = {
0101         "symbols": ["type Acts::Keep", "type Acts::New"],
0102         "callables": {
0103             "Acts::foo(int)": "void",
0104             "Acts::foo(int, double)": "void",
0105             "Acts::bar(int, double)": "void",
0106             "Acts::baz()": "long",
0107         },
0108         "fields": {"Acts::S::a": "float", "Acts::S::added": "bool"},
0109     }
0110     c = pad.classify(base, head)
0111     # additions
0112     assert "type Acts::New" in c["added_names"]
0113     assert "Acts::foo(int, double)" in c["added_signatures"]  # defaulted-arg add
0114     assert "Acts::S::added" in c["added_fields"]
0115     # breaking
0116     assert "type Acts::Old" in c["removed_names"]
0117     assert "Acts::gone(double)" in c["removed_signatures"]
0118     assert any("Acts::baz()" in s for s in c["return_type_changes"])
0119     assert "Acts::S::gone" in c["removed_fields"]
0120     assert any("Acts::S::a" in s for s in c["field_type_changes"])
0121     assert c["has_additions"] and c["has_breaking"]
0122 
0123 
0124 def test_classify_defaulted_arg_is_not_breaking():
0125     base = {"symbols": [], "callables": {"Acts::f(int)": "void"}, "fields": {}}
0126     head = {
0127         "symbols": [],
0128         "callables": {"Acts::f(int)": "void", "Acts::f(int, double)": "void"},
0129         "fields": {},
0130     }
0131     c = pad.classify(base, head)
0132     assert c["has_additions"] and not c["has_breaking"], c
0133 
0134 
0135 def test_classify_no_change():
0136     snap = {
0137         "symbols": ["type Acts::A"],
0138         "callables": {"Acts::f()": "void"},
0139         "fields": {"Acts::A::x": "int"},
0140     }
0141     c = pad.classify(snap, snap)
0142     assert not c["has_additions"] and not c["has_breaking"]
0143 
0144 
0145 # --- end-to-end fixture test ----------------------------------------------
0146 
0147 
0148 def _measure(input_dir: Path) -> dict:
0149     out = Path(tempfile.mkdtemp(prefix="api-selftest-"))
0150     pas.run_doxygen(repo=HERE.parents[1], out=out, input_dirs=[input_dir])
0151     return pas.parse_xml(out / "xml")
0152 
0153 
0154 def test_end_to_end_fixture():
0155     if not shutil.which("doxygen"):
0156         print("  (skipped: doxygen not on PATH)")
0157         return
0158     data = HERE / "testdata"
0159     base = _measure(data / "base" / "Acts")
0160     head = _measure(data / "head" / "Acts")
0161     c = pad.classify(base, head)
0162 
0163     # additions
0164     assert "type Acts::NewThing" in c["added_names"], c
0165     assert "Acts::Demo::flag" in c["added_fields"], c
0166     assert any("Acts::Demo::doThing(int, int)" == s for s in c["added_signatures"]), c
0167     assert any(
0168         s.startswith("Acts::compute(int, double)") for s in c["added_signatures"]
0169     ), c
0170     # breaking
0171     assert any(s == "Acts::Demo::doThing(int)" for s in c["removed_signatures"]), c
0172     assert any(
0173         s.startswith("Acts::Demo::oldFn(double)") for s in c["removed_signatures"]
0174     ), c
0175     assert any("Acts::Demo::tolerance" in s for s in c["field_type_changes"]), c
0176     # compute(int) must survive (defaulted arg) -> not a removal
0177     assert not any(s == "Acts::compute(int)" for s in c["removed_signatures"]), c
0178 
0179 
0180 def main() -> int:
0181     tests = [
0182         v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)
0183     ]
0184     failed = 0
0185     for t in tests:
0186         try:
0187             t()
0188             print(f"PASS {t.__name__}")
0189         except AssertionError as e:
0190             failed += 1
0191             print(f"FAIL {t.__name__}: {e}")
0192         except Exception as e:  # noqa: BLE001
0193             failed += 1
0194             print(f"ERROR {t.__name__}: {type(e).__name__}: {e}")
0195     print(f"\n{len(tests) - failed}/{len(tests)} passed")
0196     return 1 if failed else 0
0197 
0198 
0199 if __name__ == "__main__":
0200     sys.exit(main())