diff --git a/.github/workflows/test_python_scripts.yml b/.github/workflows/test_python_scripts.yml index 717ee6143..2d99d1df2 100644 --- a/.github/workflows/test_python_scripts.yml +++ b/.github/workflows/test_python_scripts.yml @@ -16,9 +16,11 @@ jobs: with: python-version: "3.12" - name: Install dependencies - run: pip install pytest pytest-cov + run: pip install pytest pytest-cov pandas lxml - name: Run tests - run: python -m pytest scripts/*.py --cov=scripts --cov-report=xml + run: >- + python -m pytest scripts/*.py tests/resources/test_generate_rtde_outputs.py + --cov=scripts --cov=generate_rtde_outputs --cov-report=xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v7 with: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5479ca8b7..34567fac8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -217,6 +217,11 @@ target_link_libraries(rtde_parser_tests PRIVATE ur_client_library::urcl GTest::g gtest_add_tests(TARGET rtde_parser_tests ) +# Metadata parsing/filtering is tested offline, independently of documentation downloads or a robot. +add_executable(rtde_output_recipe_metadata_tests test_rtde_output_recipe_metadata.cpp) +target_link_libraries(rtde_output_recipe_metadata_tests PRIVATE ur_client_library::urcl GTest::gtest_main) +gtest_add_tests(TARGET rtde_output_recipe_metadata_tests) + add_executable(tcp_server_tests test_tcp_server.cpp) if (MSVC) target_compile_options(tcp_server_tests PRIVATE /Zc:lambda) diff --git a/tests/resources/generate_rtde_outputs.py b/tests/resources/generate_rtde_outputs.py index cf4f527b9..52deae61c 100644 --- a/tests/resources/generate_rtde_outputs.py +++ b/tests/resources/generate_rtde_outputs.py @@ -28,27 +28,152 @@ # POSSIBILITY OF SUCH DAMAGE. # -- END LICENSE BLOCK ------------------------------------------------ +"""Generate independent library and documentation RTDE output lists. + +The exhaustive recipe remains plain field names extracted from the C++ source. +The documentation output is TEST ONLY TSV, without a header: + namePS5 availabilityPSX availability +Availability is '*' (unrestricted), '-' (unavailable), or major.minor.patch. +Do not pass this metadata file to the production RTDE recipe reader. +""" + import pathlib -import pandas as pd import re + +import pandas as pd + URCL_PATH = pathlib.Path(__file__).parent.parent.parent.resolve() +OUTPUT_PATH = pathlib.Path(__file__).parent / "exhaustive_rtde_output_recipe.txt" +WEB_OUTPUT_PATH = pathlib.Path(__file__).parent / "docs_rtde_output_recipe.txt" +DOCS_URL = "https://docs.universal-robots.com/tutorials/communication-protocol-tutorials/rtde-guide.html" +VERSION_COLUMN = "Introduced in Version" +REQUIRED_COLUMNS = {"Name", "Type", "Comment", VERSION_COLUMN} +BASELINES = {5: (5, 9, 0), 10: (10, 10, 0)} +VERSION_PATTERN = r"[0-9]+\.[0-9]+(?:\.[0-9]+)?" +REGISTER_PATTERN = r"(?:input|output)_(?:bit|int|double)_register_X" + + +def cell_text(value): + """Normalize empty pandas cells and documentation whitespace.""" + if pd.isna(value): + return "" + if not isinstance(value, str): + raise ValueError(f"Expected a documentation text cell, got {value!r}") + return " ".join(value.split()) + + +def parse_availability(name, annotation): + """Parse field introductions, resolving family presence before cutoffs.""" + text = cell_text(annotation) + # This exact documented annotation introduces a new bit, NOT the field. + # Keep the exception narrow so future field/bit annotations need review. + if (name == "safety_status_bits" + and text == "5.25.0 / 10.12.0 Is 3PE input active"): + return "*", "*" + + # The upper half of these registers was introduced before our PS5 baseline. + # Do not apply range-specific thresholds to every register: only this known + # legacy annotation is safe to discard. Other ranges/versions fail below. + if (re.fullmatch(r"(?:input|output)_(?:int|double)_register_X", name) + and re.fullmatch(r"\[\s*24\s*\.\.\s*47\s*\]\s*5\.3\.0", text)): + return "*", "*" + if "[" in text or "]" in text: + raise ValueError(f"{name}: unsupported range-specific version annotation {text!r}") + if not text: + return "*", "*" + if not re.fullmatch(rf"{VERSION_PATTERN}(?:\s*/\s*{VERSION_PATTERN})*", text): + raise ValueError(f"{name}: malformed version annotation {text!r}") -PKG_PATH = [i for i in pathlib.Path(URCL_PATH.as_posix()).glob("**/data_package.cpp")] + versions = {} + for token in text.split("/"): + components = tuple(int(part) for part in token.strip().split(".")) + version = components if len(components) == 3 else (*components, 0) + major = version[0] + if major not in (3, 5, 10): + raise ValueError(f"{name}: unsupported version family in {text!r}") + if major in versions: + raise ValueError(f"{name}: duplicate version family in {text!r}") + versions[major] = version -assert len(PKG_PATH) == 1 + availability = [] + for major in (5, 10): + version = versions.get(major) + if version is None: + availability.append("-" if major == 5 and 10 in versions else "*") + elif version < BASELINES[major]: + availability.append("*") + else: + availability.append(".".join(str(part) for part in version)) + return tuple(availability) -PKG_PATH = PKG_PATH[0].resolve() -OUTPUT_PATH = pathlib.Path(__file__).parent.resolve().as_posix() + "/exhaustive_rtde_output_recipe.txt" -WEB_OUTPUT_PATH = pathlib.Path(__file__).parent.resolve().as_posix() + "/docs_rtde_output_recipe.txt" +def expand_register_names(name, comment): + """Expand the documented count from the first register address, in order.""" + if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name): + raise ValueError(f"Invalid output field name {name!r}") + if not name.endswith("_X"): + return [name] + if not re.fullmatch(REGISTER_PATTERN, name): + raise ValueError(f"Unsupported register placeholder {name!r}") + text = cell_text(comment) + count = re.match(r"([0-9]+)\s+general purpose\b", text) + ranges = re.findall(r"\[\s*([0-9]+)\s*\.\.\s*([0-9]+)\s*\]", text) + if count is None or not ranges: + raise ValueError(f"{name}: malformed register description {text!r}") + amount = int(count.group(1)) + base = int(ranges[0][0]) + next_address = base + for start, end in ranges: + if int(start) != next_address or int(end) < int(start): + raise ValueError(f"{name}: noncontiguous register ranges {text!r}") + next_address = int(end) + 1 + if amount == 0 or next_address != base + amount: + raise ValueError(f"{name}: register count/range mismatch {text!r}") + return [name[:-1] + str(base + offset) for offset in range(amount)] -with open(PKG_PATH) as pkg_file: + +def validate_output_table(table): + """Fail clearly if the public documentation schema changes.""" + if not isinstance(table, pd.DataFrame): + raise ValueError("Expected an RTDE output DataFrame") + if not table.columns.is_unique or not REQUIRED_COLUMNS.issubset(table.columns): + raise ValueError(f"RTDE output table requires columns {sorted(REQUIRED_COLUMNS)!r}") + if table.empty: + raise ValueError("RTDE output table is empty") + + +def select_output_table(tables): + """The public guide's second table is outputs; verify its timestamp anchor.""" + if len(tables) < 2: + raise ValueError("RTDE documentation is missing the second (output) table") + table = tables[1] + validate_output_table(table) + if "timestamp" not in table["Name"].values: + raise ValueError("The second documentation table is not the RTDE output table (missing timestamp)") + return table + + +def documentation_records(table): + """Return ordered, exhaustive test metadata; never filter out old fields.""" + validate_output_table(table) + records = [] + seen = set() + for _, row in table.iterrows(): + name = cell_text(row["Name"]) + availability = parse_availability(name, row[VERSION_COLUMN]) + for field in expand_register_names(name, row["Comment"]): + if field in seen: + raise ValueError(f"Duplicate documented output field {field!r}") + seen.add(field) + records.append((field, *availability)) + return records + + +def extract_exhaustive_outputs(lines): + """Keep the original source-based extraction independent of documentation.""" save_outputs = False outputs = [] - while True: - line = pkg_file.readline() - if not line: - break + for line in lines: if "// INPUT / OUTPUT" in line: save_outputs = True if save_outputs: @@ -56,24 +181,25 @@ outputs.append(line.split('"')[1] + "\n") if "// NOT IN OFFICIAL DOCS" in line: break + return outputs + +def main(): + """Generate both resources with the existing no-argument CLI.""" + package_paths = list(URCL_PATH.glob("**/data_package.cpp")) + if len(package_paths) != 1: + raise ValueError(f"Expected one data_package.cpp, found {len(package_paths)}") + with package_paths[0].open() as pkg_file: + outputs = extract_exhaustive_outputs(pkg_file) + + # Preserve two-component versions as text (float inference loses 10.10). + tables = pd.read_html(DOCS_URL, converters={VERSION_COLUMN: str}) + records = documentation_records(select_output_table(tables)) with open(OUTPUT_PATH, "w") as output_file: output_file.writelines(outputs) + with open(WEB_OUTPUT_PATH, "w") as web_output_file: + web_output_file.writelines("\t".join(record) + "\n" for record in records) + -# Get outputs from official docs -page = pd.read_html("https://docs.universal-robots.com/tutorials/communication-protocol-tutorials/rtde-guide.html") -table = page[1] -outputs = [] -for _, row in table.iterrows(): - name = row["Name"] - if "X" in name: - numbers = re.findall(r'\d+', row["Comment"]) - amount = int(numbers[0]) - base_addr = int(numbers[1]) - for i in range(amount): - outputs.append(name[:-1]+str(base_addr+i) + "\n") - else: - outputs.append(name + "\n") - -with open(WEB_OUTPUT_PATH, "w") as web_output_file: - web_output_file.writelines(outputs) +if __name__ == "__main__": + main() diff --git a/tests/resources/test_generate_rtde_outputs.py b/tests/resources/test_generate_rtde_outputs.py new file mode 100644 index 000000000..cf031dfa3 --- /dev/null +++ b/tests/resources/test_generate_rtde_outputs.py @@ -0,0 +1,248 @@ +"""Offline regression tests for TEST ONLY RTDE documentation metadata.""" + +import importlib.util +import io +import pathlib +import runpy +import sys +from unittest.mock import mock_open + +import pandas as pd +import pytest + +import generate_rtde_outputs as generator + + +def make_table(*rows): + return pd.DataFrame(rows, columns=["Name", "Type", "Comment", generator.VERSION_COLUMN]) + + +@pytest.mark.parametrize("annotation, expected", [ + (None, ("*", "*")), + (float("nan"), ("*", "*")), + (pd.NA, ("*", "*")), + ("", ("*", "*")), + (" \t\n ", ("*", "*")), + ("5.8.99 / 10.9.99", ("*", "*")), + ("5.9.0 / 10.10.0", ("5.9.0", "10.10.0")), + ("5.9 / 10.10", ("5.9.0", "10.10.0")), + ("5.23.0 / 10.11.0", ("5.23.0", "10.11.0")), + (" \n10.12.0\t /\u00a0 5.26.0 \n", ("5.26.0", "10.12.0")), + ("10.14/5.26", ("5.26.0", "10.14.0")), + ("5.11.0", ("5.11.0", "*")), + ("5.3.0", ("*", "*")), + ("10.15", ("-", "10.15.0")), + ("10.15.1", ("-", "10.15.1")), + ("10.10.0", ("-", "10.10.0")), + ("10.9.99", ("-", "*")), + ("10.7", ("-", "*")), + ("3.14.3", ("*", "*")), + ("3.14 / 10.7", ("-", "*")), + ("3.14.3 / 5.9.0 / 10.11.0", ("5.9.0", "10.11.0")), + ("5.3 / 10.15", ("*", "10.15.0")), + ("5.11 / 10.7", ("5.11.0", "*")), +]) +def test_availability(annotation, expected): + assert generator.parse_availability("field", annotation) == expected + + +@pytest.mark.parametrize("annotation", [ + "5", "10", "5.9.0.1", "v5.9", "5.9.", ".5.9", "5..9", + "5.9 /", "/ 5.9", "5.9 // 10.10", "5.9, 10.10", "5.9 10.10", + "5.9.0 new feature", "available since 5.9.0", "-", "*", "NaN", + "5.9e0", "-5.9", "5.-9", "5.9 / 11.1", "4.0", "6.0", + "5.9 / 5.10", "10.10 / 10.11", "3.1 / 3.2", 5.9, 10, +]) +def test_malformed_annotations_fail(annotation): + with pytest.raises(ValueError): + generator.parse_availability("field", annotation) + + +def test_safety_bit_exception_is_explicit_and_narrow(): + annotation = "5.25.0 / 10.12.0 Is 3PE input active" + assert generator.parse_availability("safety_status_bits", annotation) == ("*", "*") + assert generator.parse_availability("safety_status_bits", annotation.replace(" / ", "\n/\t")) == ("*", "*") + assert generator.parse_availability("safety_status_bits", "5.25.0 / 10.12.0") == ("5.25.0", "10.12.0") + with pytest.raises(ValueError, match="other_field"): + generator.parse_availability("other_field", annotation) + with pytest.raises(ValueError, match="malformed"): + generator.parse_availability("safety_status_bits", annotation.replace("5.25.0", "5.26.0")) + + +@pytest.mark.parametrize("direction", ["input", "output"]) +@pytest.mark.parametrize("kind", ["int", "double"]) +def test_legacy_register_range(direction, kind): + name = f"{direction}_{kind}_register_X" + comment = f"48 general purpose {kind} registers (X: [0..23] reserved for Fieldbus/PLC, [24..47] for external RTDE clients)" + records = generator.documentation_records(make_table((name, "INT32", comment, "[24..47] 5.3.0"))) + assert records == [(name[:-1] + str(i), "*", "*") for i in range(48)] + + +@pytest.mark.parametrize("annotation", [ + "[24..47] 5.9.0", "[24..47] 10.10.0", "[24..47] 10.7", + "[24..47] 5.3.0 / 10.15", "[0..23] 5.3.0", "[24..47] nonsense", +]) +def test_unsupported_register_version_ranges_fail(annotation): + with pytest.raises(ValueError, match="range-specific"): + generator.parse_availability("output_int_register_X", annotation) + + +def test_legacy_range_requires_register_field(): + with pytest.raises(ValueError, match="range-specific"): + generator.parse_availability("field", "[24..47] 5.3.0") + + +@pytest.mark.parametrize("direction", ["input", "output"]) +def test_bit_register_expansion_inherits_metadata(direction): + name = f"{direction}_bit_register_X" + comment = "64 general purpose bits (X: [64..127]) reserved for external RTDE clients" + table = make_table( + ("timestamp", "DOUBLE", None, None), + (name, "BOOL", comment, "5.23.0 / 10.11.0"), + ("tool_output_voltage_2", "INT32", "Tool Flange V2", "10.15"), + ("old_x_only", "DOUBLE", None, "10.7"), + ) + records = generator.documentation_records(table) + assert records == [ + ("timestamp", "*", "*"), + *[(name[:-1] + str(i), "5.23.0", "10.11.0") for i in range(64, 128)], + ("tool_output_voltage_2", "-", "10.15.0"), + ("old_x_only", "-", "*"), + ] + + +def test_comments_do_not_supply_introduction_versions(): + table = make_table(("actual_TCP_acceleration", "VECTOR6D", "5.26.0 / 10.12.0 in comment only", None)) + assert generator.documentation_records(table) == [("actual_TCP_acceleration", "*", "*")] + + +@pytest.mark.parametrize("name, comment", [ + ("", ""), ("field\tbad", ""), ("field bad", ""), ("field\nbad", ""), + ("unknown_X", "1 general purpose [0..0]"), + ("output_int_register_X", None), + ("output_int_register_X", "48 registers [0..47]"), + ("output_int_register_X", "48 general purpose registers"), + ("output_int_register_X", "48 general purpose registers [0..46]"), + ("output_int_register_X", "48 general purpose registers [0..23] [25..48]"), + ("output_int_register_X", "48 general purpose registers [0..24] [24..47]"), + ("output_int_register_X", "48 general purpose registers [47..0]"), + ("output_int_register_X", "0 general purpose registers [0..0]"), +]) +def test_invalid_names_and_register_descriptions(name, comment): + with pytest.raises(ValueError): + generator.expand_register_names(name, comment) + + +def test_duplicate_expanded_fields_fail(): + table = make_table( + ("output_int_register_X", "INT32", "2 general purpose registers [0..1]", None), + ("output_int_register_1", "INT32", "", None), + ) + with pytest.raises(ValueError, match="Duplicate.*output_int_register_1"): + generator.documentation_records(table) + + +def test_validate_table_and_columns(): + valid = make_table(("timestamp", "DOUBLE", "", None)) + assert generator.select_output_table([pd.DataFrame(), valid]) is valid + for column in generator.REQUIRED_COLUMNS: + with pytest.raises(ValueError, match="requires columns"): + generator.documentation_records(valid.drop(columns=column)) + with pytest.raises(ValueError, match="requires columns"): + generator.documentation_records(pd.concat([valid, valid[["Name"]]], axis=1)) + with pytest.raises(ValueError, match="empty"): + generator.documentation_records(make_table()) + with pytest.raises(ValueError, match="DataFrame"): + generator.documentation_records(None) + with pytest.raises(ValueError, match="missing.*table"): + generator.select_output_table([valid]) + with pytest.raises(ValueError, match="missing timestamp"): + generator.select_output_table([valid, make_table(("input", "INT32", "", None))]) + + +def test_html_column_contract_offline(): + html = """ + + +
NameTypeCommentIntroduced in Version
timestampDOUBLETime elapsed
tool_output_current_2DOUBLETool V210.15
at_cutoffDOUBLE10.10
""" + table = pd.read_html(io.StringIO(html), flavor="lxml", converters={generator.VERSION_COLUMN: str})[0] + assert generator.documentation_records(table) == [ + ("timestamp", "*", "*"), ("tool_output_current_2", "-", "10.15.0"), + ("at_cutoff", "-", "10.10.0"), + ] + + +SOURCE = '''// unrelated +{"input_only", value}, +// INPUT / OUTPUT +{"timestamp", value}, +// comment + +// OUTPUT +{"library_only", value}, +// NOT IN OFFICIAL DOCS +{"undocumented", value}, +''' + + +def test_exhaustive_extraction_remains_independent(): + assert generator.extract_exhaustive_outputs(io.StringIO(SOURCE)) == ["timestamp\n", "library_only\n"] + + +def test_import_has_no_network_source_search_or_resource_io(monkeypatch): + spec = importlib.util.spec_from_file_location("generator_import_safety", generator.__file__) + module = importlib.util.module_from_spec(spec) + + def forbidden(*args, **kwargs): + pytest.fail("Import must not fetch docs, discover sources, or open resource files") + + monkeypatch.setattr(sys, "dont_write_bytecode", True) + monkeypatch.setattr(pd, "read_html", forbidden) + monkeypatch.setattr(pathlib.Path, "glob", forbidden) + monkeypatch.setattr(pathlib.Path, "open", forbidden) + monkeypatch.setattr("builtins.open", forbidden) + spec.loader.exec_module(module) + assert callable(module.main) + + +def test_main_outputs_and_cli_guard(tmp_path, monkeypatch): + source = tmp_path / "data_package.cpp" + source.write_text(SOURCE) + exhaustive = tmp_path / "exhaustive.txt" + docs = tmp_path / "docs.txt" + monkeypatch.setattr(generator, "URCL_PATH", tmp_path) + monkeypatch.setattr(generator, "OUTPUT_PATH", exhaustive) + monkeypatch.setattr(generator, "WEB_OUTPUT_PATH", docs) + + def read_html(url, converters): + assert url == generator.DOCS_URL + assert converters == {generator.VERSION_COLUMN: str} + return [pd.DataFrame(), make_table( + ("timestamp", "DOUBLE", "", None), + ("docs_only", "DOUBLE", "", "10.7"), + ("tool_output_voltage_2", "INT32", "", "10.15"), + )] + + monkeypatch.setattr(pd, "read_html", read_html) + generator.main() + assert exhaustive.read_text() == "timestamp\nlibrary_only\n" + assert docs.read_text() == "timestamp\t*\t*\ndocs_only\t-\t*\ntool_output_voltage_2\t-\t10.15.0\n" + assert all(len(line.split("\t")) == 3 for line in docs.read_text().splitlines()) + # Exercise the unchanged no-argument script entry point without writing any + # real repository resources. Path.open still reads only the temporary source. + monkeypatch.setattr(pathlib.Path, "glob", lambda self, pattern: iter([source])) + mocked_open = mock_open() + monkeypatch.setattr("builtins.open", mocked_open) + runpy.run_path(generator.__file__, run_name="__main__") + assert mocked_open.call_count == 2 + + +@pytest.mark.parametrize("source_count", [0, 2]) +def test_main_requires_unique_source(tmp_path, monkeypatch, source_count): + for index in range(source_count): + folder = tmp_path / str(index) + folder.mkdir() + (folder / "data_package.cpp").write_text(SOURCE) + monkeypatch.setattr(generator, "URCL_PATH", tmp_path) + with pytest.raises(ValueError, match="Expected one data_package.cpp"): + generator.main() \ No newline at end of file diff --git a/tests/rtde_output_recipe_metadata.h b/tests/rtde_output_recipe_metadata.h new file mode 100644 index 000000000..48a9d72d0 --- /dev/null +++ b/tests/rtde_output_recipe_metadata.h @@ -0,0 +1,151 @@ +// Test-only metadata for output fields extracted from the public RTDE documentation. +#ifndef URCL_TESTS_RTDE_OUTPUT_RECIPE_METADATA_H +#define URCL_TESTS_RTDE_OUTPUT_RECIPE_METADATA_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace urcl +{ +namespace test +{ +// Headerless TSV: name, PolyScope 5 availability, PolyScope X availability. +// '*' means unrestricted; '-' means unavailable; otherwise use major.minor.patch. +// This is NOT the recipe format accepted by RTDEClient's file constructor. +struct OutputAvailability +{ + std::string polyscope5; + std::string polyscopex; + + bool availableOn(const VersionInformation& controller) const + { + if (controller.major != 5 && controller.major != 10) + { + throw std::runtime_error("RTDE documentation metadata only supports PolyScope 5 and X"); + } + const auto& requirement = controller.major == 5 ? polyscope5 : polyscopex; + if (requirement == "*" || requirement == "-") + { + return requirement == "*"; + } + const auto minimum = VersionInformation::fromString(requirement); + // Controller build numbers do not affect documented introduction versions. + return std::tie(controller.major, controller.minor, controller.bugfix) >= + std::tie(minimum.major, minimum.minor, minimum.bugfix); + } +}; + +using OutputMetadata = std::map; + +inline void validateAvailability(const std::string& value, uint32_t family) +{ + if (value == "*" || value == "-") + { + return; + } + if (!std::regex_match(value, std::regex("[0-9]+\\.[0-9]+\\.[0-9]+")) || + VersionInformation::fromString(value).major != family) + { + throw std::runtime_error("Invalid availability '" + value + "' for family " + std::to_string(family)); + } +} + +inline OutputMetadata readOutputMetadata(std::istream& input) +{ + OutputMetadata metadata; + std::string line; + size_t line_number = 0; + while (std::getline(input, line)) + { + ++line_number; + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + try + { + const auto first = line.find('\t'); + const auto second = first == std::string::npos ? std::string::npos : line.find('\t', first + 1); + if (first == std::string::npos || second == std::string::npos || line.find('\t', second + 1) != std::string::npos) + { + throw std::runtime_error("Expected name, PolyScope 5 and PolyScope X tab-separated columns"); + } + const auto name = line.substr(0, first); + if (!std::regex_match(name, std::regex("[A-Za-z_][A-Za-z_0-9]*"))) + { + throw std::runtime_error("Invalid field name '" + name + "'"); + } + const OutputAvailability availability{ line.substr(first + 1, second - first - 1), line.substr(second + 1) }; + validateAvailability(availability.polyscope5, 5); + validateAvailability(availability.polyscopex, 10); + if (!metadata.emplace(name, availability).second) + { + throw std::runtime_error("Duplicate field '" + name + "'"); + } + } + catch (const std::exception& error) + { + const auto context = "RTDE metadata line " + std::to_string(line_number) + " (" + line + "): "; + throw std::runtime_error(context + error.what()); + } + } + if (input.bad() || metadata.empty()) + { + throw std::runtime_error("Unreadable or empty RTDE output metadata"); + } + return metadata; +} + +inline OutputMetadata loadOutputMetadata(const std::string& path) +{ + std::ifstream input(path); + if (!input) + { + throw std::runtime_error("Cannot open RTDE output metadata: " + path); + } + return readOutputMetadata(input); +} + +inline bool supportsOutputMetadata(const VersionInformation& controller) +{ + return (controller.major == 5 && controller.minor >= 9) || (controller.major == 10 && controller.minor >= 10); +} + +inline std::vector filterOutputRecipe(const std::vector& recipe, + const OutputMetadata& metadata, const VersionInformation& controller, + std::ostream& excluded) +{ + if (!supportsOutputMetadata(controller)) + { + throw std::runtime_error("RTDE output metadata requires PolyScope 5 >= 5.9.0 or PolyScope X >= 10.10.0"); + } + std::vector result; + for (const auto& name : recipe) + { + const auto entry = metadata.find(name); + if (entry == metadata.end()) + { + throw std::runtime_error("Missing RTDE documentation metadata for '" + name + "'"); + } + if (entry->second.availableOn(controller)) + { + result.push_back(name); + } + else + { + excluded << "Skipping RTDE field " << name << " on " << controller.toString() << " (requires " + << (controller.major == 5 ? entry->second.polyscope5 : entry->second.polyscopex) << ")\n"; + } + } + return result; +} +} // namespace test +} // namespace urcl + +#endif \ No newline at end of file diff --git a/tests/test_rtde_client.cpp b/tests/test_rtde_client.cpp index 1fcff8b8f..ec5686b2b 100644 --- a/tests/test_rtde_client.cpp +++ b/tests/test_rtde_client.cpp @@ -43,6 +43,7 @@ #include #include "fake_rtde_server.h" +#include "rtde_output_recipe_metadata.h" #include "ur_client_library/log.h" using namespace urcl; @@ -684,29 +685,40 @@ TEST_F(RTDEClientTest, connect_non_running_robot) TEST_F(RTDEClientTest, check_all_rtde_output_variables_exist) { - const char* env_var = std::getenv("URSIM_VERSION"); - if (env_var == nullptr) +#ifndef CHECK_RTDE_DOCS_RECIPE + GTEST_SKIP() << "Enable CHECK_RTDE_DOCS_RECIPE to generate version-aware RTDE output metadata."; +#else + // Query the actual controller using only a universally available output and no inputs. + client_.reset(); + client_.reset(new rtde_interface::RTDEClient(g_ROBOT_IP, notifier_, std::vector{ "timestamp" }, + std::vector{})); + ASSERT_TRUE(client_->init()); + const auto version = client_->getVersion(); + client_.reset(); + if (!test::supportsOutputMetadata(version)) { - std::cout << "No URSIM_VERSION environment variable set, skipping test." << std::endl; - GTEST_SKIP(); + GTEST_SKIP() << "Version-aware exhaustive output testing requires PolyScope 5 >= 5.9.0 or PolyScope X >= 10.10.0; " + "got " + << version.toString(); } - const std::string env_ursim_version(env_var); - if (env_ursim_version != "latest") + const auto metadata = test::loadOutputMetadata(docs_output_recipe_file_); + std::ifstream pkg_file(exhaustive_output_recipe_file_); + ASSERT_TRUE(pkg_file.is_open()) << exhaustive_output_recipe_file_; + std::vector pkg_outputs; + std::string line; + while (std::getline(pkg_file, line)) { - std::cout << "Not using the latest URSIM version, skipping test. URSIM_VERSION is set to '" << env_ursim_version - << "'" << std::endl; - GTEST_SKIP(); + pkg_outputs.push_back(line); } - - client_->init(); - - // Ignore unknown output variables to account for variables not available in old urcontrol versions. - client_.reset(new rtde_interface::RTDEClient(g_ROBOT_IP, notifier_, exhaustive_output_recipe_file_, - input_recipe_file_, 0.0, false)); - - EXPECT_TRUE(client_->init()); - client_->start(); + ASSERT_FALSE(pkg_outputs.empty()); + const auto output_recipe = test::filterOutputRecipe(pkg_outputs, metadata, version, std::cout); + ASSERT_FALSE(output_recipe.empty()); + // Eligible fields must be accepted: never silently ignore NOT_FOUND responses. + client_.reset( + new rtde_interface::RTDEClient(g_ROBOT_IP, notifier_, output_recipe, std::vector{}, 0.0, false)); + ASSERT_TRUE(client_->init()); + ASSERT_TRUE(client_->start()); // Test that we can receive and parse the timestamp from the received package to prove the setup was successful const std::chrono::milliseconds read_timeout{ 100 }; @@ -718,24 +730,28 @@ TEST_F(RTDEClientTest, check_all_rtde_output_variables_exist) EXPECT_GT(timestamp, 0.0); client_->pause(); +#endif } #ifdef CHECK_RTDE_DOCS_RECIPE TEST_F(RTDEClientTest, check_rtde_data_fields_match_docs) { - std::ifstream docs_file(docs_output_recipe_file_); + const auto metadata = test::loadOutputMetadata(docs_output_recipe_file_); std::ifstream pkg_file(exhaustive_output_recipe_file_); + ASSERT_TRUE(pkg_file.is_open()) << exhaustive_output_recipe_file_; std::vector docs_outputs; - std::string line; - while (std::getline(docs_file, line)) + // Completeness is independent of the controller and includes every documented field. + for (const auto& entry : metadata) { - docs_outputs.push_back(line); + docs_outputs.push_back(entry.first); } std::vector pkg_outputs; + std::string line; while (std::getline(pkg_file, line)) { pkg_outputs.push_back(line); } + ASSERT_FALSE(pkg_outputs.empty()); std::sort(docs_outputs.begin(), docs_outputs.end()); std::sort(pkg_outputs.begin(), pkg_outputs.end()); if (!std::is_permutation(docs_outputs.begin(), docs_outputs.end(), pkg_outputs.begin(), pkg_outputs.end())) diff --git a/tests/test_rtde_output_recipe_metadata.cpp b/tests/test_rtde_output_recipe_metadata.cpp new file mode 100644 index 000000000..b483577ad --- /dev/null +++ b/tests/test_rtde_output_recipe_metadata.cpp @@ -0,0 +1,140 @@ +#include +#include +#include "rtde_output_recipe_metadata.h" + +using namespace urcl; + +namespace +{ +test::OutputMetadata exampleMetadata() +{ + std::istringstream input("timestamp\t*\t*\n" + "ft_raw_wrench\t5.9.0\t*\n" + "payload_inertia\t5.11.0\t*\n" + "actual_current_as_torque\t5.23.0\t10.11.0\n" + "target_gravity\t5.26.0\t10.12.0\n" + "control_step\t5.26.0\t10.13.0\n" + "target_base_wrench\t5.26.0\t10.14.0\n" + "tool_output_voltage_2\t-\t10.15.0\n" + "safety_status_bits\t*\t*\n"); + return test::readOutputMetadata(input); +} +} // namespace + +TEST(RTDEOutputRecipeMetadataTest, independent_family_thresholds) +{ + const auto metadata = exampleMetadata(); + const auto& torque = metadata.at("actual_current_as_torque"); + for (const auto& version : { "5.22.9.99999", "10.10.9.99999" }) + { + EXPECT_FALSE(torque.availableOn(VersionInformation::fromString(version))) << version; + } + for (const auto& version : { "5.23.0", "5.26.0", "10.11.0", "10.14.0" }) + { + EXPECT_TRUE(torque.availableOn(VersionInformation::fromString(version))) << version; + } + const test::OutputAvailability patch{ "5.23.2", "10.11.2" }; + EXPECT_FALSE(patch.availableOn(VersionInformation::fromString("5.23.1.99999"))); + EXPECT_TRUE(patch.availableOn(VersionInformation::fromString("5.23.2"))); + EXPECT_TRUE(patch.availableOn(VersionInformation::fromString("10.12.0"))); + EXPECT_FALSE(patch.availableOn(VersionInformation::fromString("10.11.1.99999"))); +} + +TEST(RTDEOutputRecipeMetadataTest, missing_family_and_unrestricted_fields) +{ + const auto metadata = exampleMetadata(); + EXPECT_FALSE(metadata.at("payload_inertia").availableOn(VersionInformation::fromString("5.9.0"))); + EXPECT_TRUE(metadata.at("payload_inertia").availableOn(VersionInformation::fromString("5.11.0"))); + EXPECT_TRUE(metadata.at("payload_inertia").availableOn(VersionInformation::fromString("10.10.0"))); + EXPECT_FALSE(metadata.at("tool_output_voltage_2").availableOn(VersionInformation::fromString("5.99.0"))); + EXPECT_FALSE(metadata.at("tool_output_voltage_2").availableOn(VersionInformation::fromString("10.14.9"))); + EXPECT_TRUE(metadata.at("tool_output_voltage_2").availableOn(VersionInformation::fromString("10.15.0"))); + for (const auto& version : { "5.9.0", "10.10.0" }) + { + EXPECT_TRUE(metadata.at("safety_status_bits").availableOn(VersionInformation::fromString(version))); + EXPECT_TRUE(metadata.at("ft_raw_wrench").availableOn(VersionInformation::fromString(version))); + } + // A below-cutoff X-only introduction must still be unavailable on PolyScope 5. + std::istringstream input("legacy_x_only\t-\t*\n"); + const auto legacy = test::readOutputMetadata(input); + EXPECT_FALSE(legacy.at("legacy_x_only").availableOn(VersionInformation::fromString("5.26.0"))); + EXPECT_TRUE(legacy.at("legacy_x_only").availableOn(VersionInformation::fromString("10.10.0"))); +} + +TEST(RTDEOutputRecipeMetadataTest, filters_in_order_without_weakening_completeness) +{ + const auto metadata = exampleMetadata(); + const std::vector recipe{ "timestamp", "target_base_wrench", "control_step", "target_gravity", + "tool_output_voltage_2" }; + const std::vector> expected{ { "timestamp" }, + { "timestamp", "target_gravity" }, + { "timestamp", "control_step", "target_gravity" }, + { "timestamp", "target_base_wrench", "control_step", + "target_gravity" }, + recipe }; + for (uint32_t minor = 11; minor <= 15; ++minor) + { + std::ostringstream excluded; + const auto controller = VersionInformation::fromString("10." + std::to_string(minor) + ".0"); + EXPECT_EQ(test::filterOutputRecipe(recipe, metadata, controller, excluded), expected.at(minor - 11)); + EXPECT_EQ(metadata.size(), 9u); // Completeness checking retains every field, including X-only ones. + if (minor < 15) + { + EXPECT_NE(excluded.str().find("tool_output_voltage_2"), std::string::npos); + EXPECT_NE(excluded.str().find("10.15.0"), std::string::npos); + } + } + std::ostringstream excluded; + EXPECT_EQ(test::filterOutputRecipe(recipe, metadata, VersionInformation::fromString("5.26.0"), excluded), + expected.at(3)); + EXPECT_THROW(test::filterOutputRecipe({ "unknown" }, metadata, VersionInformation::fromString("5.26.0"), excluded), + std::runtime_error); +} + +TEST(RTDEOutputRecipeMetadataTest, supported_controller_baselines) +{ + for (const auto& version : { "3.14.3", "5.8.99", "10.7.0", "10.9.99", "11.0.0" }) + { + const auto controller = VersionInformation::fromString(version); + EXPECT_FALSE(test::supportsOutputMetadata(controller)) << version; + std::ostringstream excluded; + const auto metadata = exampleMetadata(); + EXPECT_THROW(test::filterOutputRecipe({ "timestamp" }, metadata, controller, excluded), std::runtime_error); + } + for (const auto& version : { "5.9.0", "5.9.4", "10.10.0", "10.15.0" }) + { + EXPECT_TRUE(test::supportsOutputMetadata(VersionInformation::fromString(version))) << version; + } + EXPECT_THROW(exampleMetadata().at("timestamp").availableOn(VersionInformation::fromString("3.14.3")), + std::runtime_error); +} + +TEST(RTDEOutputRecipeMetadataTest, rejects_invalid_or_incomplete_metadata) +{ + for (const auto& text : { "", "timestamp\n", "timestamp\t*\n", "\t*\t*\n", "timestamp\t*\t*\textra\n", + "timestamp\t\t*\n", "timestamp\t*\t\n", "timestamp\t10.11.0\t*\n", "timestamp\t*\t5.23.0\n", + "timestamp\t5.23\t*\n", "timestamp\t5.23.0junk\t*\n", "timestamp\t*\t*\ntimestamp\t*\t*\n", + "invalid name\t*\t*\n", "timestamp\t5.99999999999999999999.0\t*\n" }) + { + std::istringstream input(text); + EXPECT_THROW(test::readOutputMetadata(input), std::runtime_error) << text; + } + EXPECT_THROW(test::loadOutputMetadata("missing_rtde_metadata_file.tsv"), std::runtime_error); +} + +TEST(RTDEOutputRecipeMetadataTest, reads_crlf_and_reports_bad_line_context) +{ + std::istringstream valid("timestamp\t*\t*\r\n"); + EXPECT_EQ(test::readOutputMetadata(valid).size(), 1u); + std::istringstream invalid("timestamp\t*\t*\nactual_q\t5.23\t*\n"); + try + { + test::readOutputMetadata(invalid); + FAIL() << "Malformed metadata was accepted"; + } + catch (const std::runtime_error& error) + { + EXPECT_NE(std::string(error.what()).find("line 2"), std::string::npos); + EXPECT_NE(std::string(error.what()).find("actual_q"), std::string::npos); + } +} \ No newline at end of file