diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index 84c3084..cd081e7 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -123,6 +123,7 @@ TRAINING_M41_RELEASE_INDEX_GUARD_TEST_INVENTORY = ( "test_training_m59_release_index_required_snippet_inventory_source_shape_preserves_literal_order", "test_training_m60_release_index_source_shape_fixture_guard_inventory_self_lock_is_current", "test_training_m61_release_index_source_shape_fixture_guard_methods_constant_source_shape_is_locked", + "test_training_m62_release_index_source_shape_fixture_guard_methods_reject_dynamic_constant_sources", "test_training_m53_release_index_doc_category_coverage_is_exact", "test_training_case_product_doc_reflects_current_m40_maturity", ) @@ -589,6 +590,9 @@ class DocumentationConsistencyTests(unittest.TestCase): "required_snippet_inventory_source_shape_fixture_constant_source_lock": ( "test_training_m61_release_index_source_shape_fixture_guard_methods_constant_source_shape_is_locked" ), + "required_snippet_inventory_source_shape_fixture_constant_negative_sources": ( + "test_training_m62_release_index_source_shape_fixture_guard_methods_reject_dynamic_constant_sources" + ), "per_doc_helper_exact_category_coverage": ( "test_training_m53_release_index_doc_category_coverage_is_exact" ), @@ -746,6 +750,7 @@ class DocumentationConsistencyTests(unittest.TestCase): "test_training_m59_release_index_required_snippet_inventory_source_shape_preserves_literal_order", "test_training_m60_release_index_source_shape_fixture_guard_inventory_self_lock_is_current", "test_training_m61_release_index_source_shape_fixture_guard_methods_constant_source_shape_is_locked", + "test_training_m62_release_index_source_shape_fixture_guard_methods_reject_dynamic_constant_sources", "test_training_m53_release_index_doc_category_coverage_is_exact", "test_training_case_product_doc_reflects_current_m40_maturity", ) @@ -824,18 +829,11 @@ class DocumentationConsistencyTests(unittest.TestCase): self.assertEqual(loader.errors, []) self.assertEqual(suite.countTestCases(), 1) - def test_training_m61_release_index_source_shape_fixture_guard_methods_constant_source_shape_is_locked( - self, - ) -> None: + @staticmethod + def _release_index_source_shape_fixture_guard_method_names_from_source( + module_source: str, + ) -> tuple[str, ...]: constant_name = "TRAINING_M41_RELEASE_INDEX_SOURCE_SHAPE_FIXTURE_GUARD_METHODS" - expected_source_shape_fixture_guards = ( - "test_training_m55_release_index_required_snippet_inventory_source_shape_is_locked", - "test_training_m56_release_index_required_snippet_inventory_source_shape_rejects_dynamic_fixtures", - "test_training_m57_release_index_required_snippet_inventory_source_shape_diagnostics_are_locked", - "test_training_m58_release_index_required_snippet_inventory_source_shape_accepts_literal_fixtures", - "test_training_m59_release_index_required_snippet_inventory_source_shape_preserves_literal_order", - ) - module_source = Path(__file__).read_text(encoding="utf-8") module_tree = ast.parse(module_source) constant_assignments = tuple( @@ -847,43 +845,124 @@ class DocumentationConsistencyTests(unittest.TestCase): and node.targets[0].id == constant_name ) - self.assertEqual(len(constant_assignments), 1) + if len(constant_assignments) != 1: + raise AssertionError( + "source shape fixture guard methods must have one assignment" + ) + constant_source = constant_assignments[0].value - self.assertIsInstance(constant_source, ast.Tuple) - self.assertTrue(constant_source.elts) - self.assertTrue( - all( - isinstance(element, ast.Constant) - and isinstance(element.value, str) - for element in constant_source.elts - ) + disallowed_source_nodes = ( + ast.Call, + ast.DictComp, + ast.GeneratorExp, + ast.List, + ast.ListComp, + ast.Name, + ast.SetComp, + ast.Starred, ) - self.assertIsNone( - next( - ( - node - for node in ast.walk(constant_source) - if isinstance( - node, - ( - ast.Call, - ast.DictComp, - ast.GeneratorExp, - ast.List, - ast.ListComp, - ast.Name, - ast.SetComp, - ), - ) - ), - None, - ) + dynamic_source_node = next( + ( + node + for node in ast.walk(constant_source) + if isinstance(node, disallowed_source_nodes) + ), + None, ) + if dynamic_source_node is not None: + dynamic_node = type(dynamic_source_node).__name__ + raise AssertionError( + "source shape fixture guard methods use dynamic source node " + f"{dynamic_node}" + ) + + if not isinstance(constant_source, ast.Tuple): + raise AssertionError( + "source shape fixture guard methods must be a literal tuple assignment" + ) + + if not constant_source.elts or not all( + isinstance(element, ast.Constant) and isinstance(element.value, str) + for element in constant_source.elts + ): + raise AssertionError( + "source shape fixture guard methods must use literal method names" + ) + + try: + method_names = ast.literal_eval(constant_source) + except (SyntaxError, ValueError) as exc: + raise AssertionError( + "source shape fixture guard methods must use literal method names" + ) from exc + + if not all(isinstance(method_name, str) for method_name in method_names): + raise AssertionError( + "source shape fixture guard methods must use literal method names" + ) + + return method_names + + def test_training_m61_release_index_source_shape_fixture_guard_methods_constant_source_shape_is_locked( + self, + ) -> None: + expected_source_shape_fixture_guards = ( + "test_training_m55_release_index_required_snippet_inventory_source_shape_is_locked", + "test_training_m56_release_index_required_snippet_inventory_source_shape_rejects_dynamic_fixtures", + "test_training_m57_release_index_required_snippet_inventory_source_shape_diagnostics_are_locked", + "test_training_m58_release_index_required_snippet_inventory_source_shape_accepts_literal_fixtures", + "test_training_m59_release_index_required_snippet_inventory_source_shape_preserves_literal_order", + ) + module_source = Path(__file__).read_text(encoding="utf-8") + self.assertEqual( - ast.literal_eval(constant_source), + self._release_index_source_shape_fixture_guard_method_names_from_source( + module_source + ), expected_source_shape_fixture_guards, ) + def test_training_m62_release_index_source_shape_fixture_guard_methods_reject_dynamic_constant_sources( + self, + ) -> None: + constant_name = "TRAINING_M41_RELEASE_INDEX_SOURCE_SHAPE_FIXTURE_GUARD_METHODS" + first_method = ( + "test_training_m55_release_index_required_snippet_inventory_source_shape_is_locked" + ) + second_method = ( + "test_training_m56_release_index_required_snippet_inventory_source_shape_rejects_dynamic_fixtures" + ) + invalid_sources = { + "tuple_call": ( + f"{constant_name} = tuple([{first_method!r}, {second_method!r}])", + "dynamic source node Call", + ), + "sorted_call": ( + f"{constant_name} = sorted(({first_method!r}, {second_method!r}))", + "dynamic source node Call", + ), + "name_alias": ( + f"fixture_guard_methods = ({first_method!r}, {second_method!r})\n" + f"{constant_name} = fixture_guard_methods", + "dynamic source node Name", + ), + "list_literal": ( + f"{constant_name} = [{first_method!r}, {second_method!r}]", + "dynamic source node List", + ), + "list_comprehension": ( + f"{constant_name} = [method for method in ({first_method!r}, {second_method!r})]", + "dynamic source node ListComp", + ), + } + + for fixture_name, (source, error_snippet) in invalid_sources.items(): + with self.subTest(fixture_name=fixture_name): + with self.assertRaisesRegex(AssertionError, re.escape(error_snippet)): + self._release_index_source_shape_fixture_guard_method_names_from_source( + source + ) + def test_training_m47_release_index_per_doc_helper_inventory_self_lock_is_current(self) -> None: helper_name = "_release_index_missing_required_snippets_by_doc" docs_guard_name = "test_training_readmes_expose_m1_m40_release_index_without_raw_test_modules"