"""Tests for explorer.py decomposition (P3-001). Acceptance criteria: - explorer.py must be under 1500 lines. - Tab modules must define their build functions locally (not re-export from explorer). - No circular imports between explorer.py and analysis.tabs. """ import ast import inspect import pathlib class TestExplorerDecomposition: """RED test: explorer.py must be under 1500 lines.""" def test_explorer_line_count_under_1500(self): path = pathlib.Path("explorer.py") lines = path.read_text(encoding="utf-8").splitlines() assert len(lines) < 1500, ( f"explorer.py has {len(lines)} lines; target is < 1500. " f"Extract tab functions and rendering helpers into analysis/tabs/." ) def test_tab_modules_define_functions_locally(self): """Each tab module must define its build_*_tab without delegating to explorer.""" tabs = [ ("analysis/tabs/compass.py", "build_compass_tab"), ("analysis/tabs/trajectories.py", "build_trajectories_tab"), ("analysis/tabs/search.py", "build_search_tab"), ("analysis/tabs/browser.py", "build_browser_tab"), ("analysis/tabs/components.py", "build_svd_components_tab"), ("analysis/tabs/quiz.py", "build_mp_quiz_tab"), ] for module_path, func_name in tabs: source = pathlib.Path(module_path).read_text(encoding="utf-8") tree = ast.parse(source) func_def = None for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name == func_name: func_def = node break assert func_def is not None, ( f"{module_path} must define {func_name}" ) # Ensure it's not a one-liner stub that imports from explorer body = func_def.body assert len(body) > 3, ( f"{module_path}.{func_name} looks like a stub ({len(body)} lines). " f"Extract the real implementation from explorer.py." ) def test_rendering_helpers_extracted(self): """Rendering helpers should not live in explorer.py.""" helpers = [ "_render_scree_plot", "_build_party_axis_figure", "_render_party_axis_chart", "_render_party_axis_chart_1d", "_render_svd_time_trajectory", "_render_voting_results", "_add_y_direction_annotations", ] source = pathlib.Path("explorer.py").read_text(encoding="utf-8") tree = ast.parse(source) defined = {node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)} for helper in helpers: assert helper not in defined, ( f"{helper} should be extracted from explorer.py " f"into analysis/tabs/_rendering.py" ) def test_no_circular_import_tabs_to_explorer(self): """Tab modules must not import from explorer.""" tab_modules = [ "analysis/tabs/compass.py", "analysis/tabs/trajectories.py", "analysis/tabs/search.py", "analysis/tabs/browser.py", "analysis/tabs/components.py", "analysis/tabs/quiz.py", "analysis/tabs/_rendering.py", ] for module_path in tab_modules: if not pathlib.Path(module_path).exists(): continue source = pathlib.Path(module_path).read_text(encoding="utf-8") assert "from explorer import" not in source, ( f"{module_path} imports from explorer.py — " f"move shared helpers to explorer_data.py or _rendering.py instead" ) assert "import explorer" not in source, ( f"{module_path} imports explorer module — " f"move shared helpers to explorer_data.py or _rendering.py instead" )