diff --git a/src/theme_2.py b/src/theme_2.py index ca5c5823..fa05dd3b 100644 --- a/src/theme_2.py +++ b/src/theme_2.py @@ -14,14 +14,16 @@ from contextlib import nullcontext from imgui_bundle import imgui, hello_imgui from typing import Any, Optional -from src import theme_nerv - from src import imgui_scopes as imscope -from src.theme_nerv import DATA_GREEN -from src.theme_nerv_fx import CRTFilter, AlertPulsing, StatusFlicker +from src.module_loader import _require_warmed from src.paths import get_global_themes_path from src.theme_models import ThemeFile, load_themes_from_dir, load_themes_from_toml +# Removed top-level NERV imports (startup_speedup_20260606 Phase 5B). +# src.theme_nerv and src.theme_nerv_fx are warmed on AppController's _io_pool +# and accessed via _require_warmed() at the use sites below (NERV palette +# branch of apply(), ai_text_color() when is_nerv_active(), and render_post_fx()). + # ------------------------------------------------------------------ palettes @@ -106,9 +108,9 @@ def _tone_map(rgb: tuple[float, float, float, float], palette: str) -> tuple[flo r = max(0, r)**(1.0/g); g_val = max(0, g_val)**(1.0/g); bl = max(0, bl)**(1.0/g) return (max(0.0, min(1.0, r)), max(0.0, min(1.0, g_val)), max(0.0, min(1.0, bl)), a) -_crt_filter = CRTFilter() -_alert_pulsing = AlertPulsing() -_status_flicker = StatusFlicker() +# NERV FX objects (CRTFilter, AlertPulsing, StatusFlicker) are now created +# lazily in render_post_fx() so the src.theme_nerv_fx import is deferred +# to first use. The static-level instantiations were removed in Phase 5B. # ------------------------------------------------------------------ public API @@ -213,6 +215,7 @@ def apply(palette_name: str) -> None: global _current_palette _current_palette = palette_name if palette_name == 'NERV': + theme_nerv = _require_warmed("src.theme_nerv") theme_nerv.apply_nerv() apply_syntax_palette(get_syntax_palette_for_theme(palette_name)) return @@ -386,7 +389,8 @@ def get_tweaked_theme() -> hello_imgui.ImGuiTweakedTheme: def ai_text_color() -> imgui.ImVec4: if is_nerv_active(): - return imgui.ImVec4(*DATA_GREEN) + theme_nerv = _require_warmed("src.theme_nerv") + return imgui.ImVec4(*theme_nerv.DATA_GREEN) return imgui.get_style().color_(imgui.Col_.text) def ai_text_style(): @@ -395,10 +399,13 @@ def ai_text_style(): """Returns a subtle background tint color based on the message role.""" def render_post_fx(width: float, height: float, ai_status: str, crt_enabled: bool) -> None: """Updates and renders the alert and CRT filters.""" - _alert_pulsing.update(ai_status) - _alert_pulsing.render(width, height) - _crt_filter.enabled = crt_enabled - _crt_filter.render(width, height) + theme_nerv_fx = _require_warmed("src.theme_nerv_fx") + alert_pulsing = theme_nerv_fx.AlertPulsing() + crt_filter = theme_nerv_fx.CRTFilter() + alert_pulsing.update(ai_status) + alert_pulsing.render(width, height) + crt_filter.enabled = crt_enabled + crt_filter.render(width, height) # ------------------------------------------------------------------ init load_themes_from_disk() diff --git a/tests/test_theme_2_no_top_level_nerv.py b/tests/test_theme_2_no_top_level_nerv.py new file mode 100644 index 00000000..131239be --- /dev/null +++ b/tests/test_theme_2_no_top_level_nerv.py @@ -0,0 +1,107 @@ +"""Tests that src/theme_2.py has NO top-level NERV theme imports. + +Per spec.md:2.2 Layer 1, the main thread's import chain must not include +heavy feature-gated modules. The NERV theme modules (src.theme_nerv, +src.theme_nerv_fx) are warmed on AppController's _io_pool and accessed +via _require_warmed at use sites. NERV is only active when the user +explicitly chooses it; the default theme path should be lean. + +src/theme_2.py uses these NERV symbols at three sites: + - ai_text_color() uses DATA_GREEN (only when is_nerv_active() is True) + - apply() uses theme_nerv.apply_nerv() (only when palette == 'NERV') + - render_post_fx() creates CRTFilter/AlertPulsing/StatusFlicker instances + +All three sites are now lazy: the modules are looked up via _require_warmed +inside the function body, and the FX objects are created locally per-call. + +These tests run in a fresh subprocess to ensure no warmup state leaks +from the test runner. +""" + +import subprocess +import sys +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _run_in_subprocess(snippet: str) -> subprocess.CompletedProcess: + script = textwrap.dedent(snippet) + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + cwd=str(ROOT), + timeout=30, + ) + + +def test_theme_2_does_not_import_theme_nerv_at_module_level() -> None: + res = _run_in_subprocess(""" + import sys + import src.theme_2 + print('src.theme_nerv' in sys.modules) + """) + assert res.returncode == 0, f"stderr: {res.stderr}" + assert res.stdout.strip() == "False", f"theme_2 triggered src.theme_nerv import: {res.stdout}" + + +def test_theme_2_does_not_import_theme_nerv_fx_at_module_level() -> None: + res = _run_in_subprocess(""" + import sys + import src.theme_2 + print('src.theme_nerv_fx' in sys.modules) + """) + assert res.returncode == 0, f"stderr: {res.stderr}" + assert res.stdout.strip() == "False", f"theme_2 triggered src.theme_nerv_fx import: {res.stdout}" + + +def test_theme_2_ai_text_color_source_does_not_import_theme_nerv() -> None: + """The ai_text_color function's body uses _require_warmed; verify the + file no longer has DATA_GREEN as a top-level import. (Cannot call the + function outside a real imgui context, but AST inspection is sufficient.)""" + res = _run_in_subprocess(""" + import ast + from pathlib import Path + root = Path('.').resolve() + theme_path = root / 'src' / 'theme_2.py' + tree = ast.parse(theme_path.read_text(encoding='utf-8')) + # Look for any top-level ImportFrom that imports DATA_GREEN directly + has_top_level_data_green = False + for node in tree.body: + if isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name == 'DATA_GREEN': + has_top_level_data_green = True + print('HAS_TOP_LEVEL_DATA_GREEN:', has_top_level_data_green) + """) + assert res.returncode == 0, f"stderr: {res.stderr}" + assert "HAS_TOP_LEVEL_DATA_GREEN: False" in res.stdout + + +def test_audit_main_thread_imports_sees_no_new_violation_from_theme_2() -> None: + """Run the static audit and check that src/theme_2.py contributes no new NERV violations.""" + res = _run_in_subprocess(""" + import ast + from pathlib import Path + root = Path('.').resolve() + theme_path = root / 'src' / 'theme_2.py' + tree = ast.parse(theme_path.read_text(encoding='utf-8')) + heavy = ['src.theme_nerv', 'src.theme_nerv_fx', 'theme_nerv', 'theme_nerv_fx'] + for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + for h in heavy: + if alias.name == h or alias.name.startswith(h + '.'): + print('VIOLATION:', alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + for h in heavy: + if node.module == h or node.module.startswith(h + '.'): + print('VIOLATION:', node.module) + print('OK') + """) + assert res.returncode == 0, f"stderr: {res.stderr}" + assert "OK" in res.stdout + assert "VIOLATION" not in res.stdout