File indexing completed on 2026-08-16 08:16:23
0001
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
0030 import public_api_diff as pad
0031
0032
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
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
0067
0068
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
0082
0083
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
0112 assert "type Acts::New" in c["added_names"]
0113 assert "Acts::foo(int, double)" in c["added_signatures"]
0114 assert "Acts::S::added" in c["added_fields"]
0115
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
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
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
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
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:
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())