diff --git a/test/test_composer_blueprint_registered.py b/test/test_composer_blueprint_registered.py new file mode 100644 index 00000000..2b56caac --- /dev/null +++ b/test/test_composer_blueprint_registered.py @@ -0,0 +1,64 @@ +"""composer_bp must actually be registered on the Flask app. + +web_interface/blueprints/composer.py defines a full blueprint -- the +/composer/ page, /composer/api/generate, /composer/api/install, etc. -- but +nothing in web_interface/app.py imported or registered it. pages_v3 and +api_v3 both go through the same "import, wire managers, register_blueprint" +sequence; composer_bp had none of the three. The routes existed and worked +in isolation (every other composer test builds its own minimal Flask app +and registers composer_bp directly), but in the real running app every +composer URL -- including the page itself -- was a 404, and the frontend's +hardcoded '/composer/api/...' fetches (composer-app.js) had nothing to +reach. + +app.py has import-time side effects (it loads config, builds managers, and +in some configurations starts background threads), so this scans the source +text rather than importing the module -- the same approach already used for +test_composer_js_contracts.py's structural checks. +""" +import re +from pathlib import Path + +APP_PY = Path(__file__).resolve().parent.parent / "web_interface" / "app.py" + + +def _source() -> str: + return APP_PY.read_text() + + +def test_composer_blueprint_is_imported(): + src = _source() + assert re.search( + r"from web_interface\.blueprints\.composer import composer_bp", src + ), "app.py never imports composer_bp" + + +def test_composer_blueprint_is_registered_under_composer_prefix(): + src = _source() + match = re.search( + r"app\.register_blueprint\(\s*composer_bp\s*,\s*url_prefix\s*=\s*" + r"['\"](?P[^'\"]*)['\"]", + src, + ) + assert match, "app.py never calls app.register_blueprint(composer_bp, ...)" + # composer.html and composer-app.js hardcode '/composer/api/...' and + # '/composer/api/fonts/...' -- any other prefix serves a page whose own + # fetches and @font-face all 404. + assert match.group("prefix") == "/composer" + + +def test_composer_blueprint_managers_are_wired_before_registration(): + """Registered with no managers set means every route 503s or crashes. + + composer.py initialises config_manager/plugin_manager/plugins_dir/ + project_root to None at import time specifically so app.py can set them + -- the same pattern api_v3 and pages_v3 use. + """ + src = _source() + register_match = re.search(r"app\.register_blueprint\(\s*composer_bp", src) + assert register_match, "app.py never registers composer_bp" + before_registration = src[: register_match.start()] + for attr in ("config_manager", "plugin_manager", "plugins_dir", "project_root"): + assert re.search(rf"composer_bp\.{attr}\s*=", before_registration), ( + f"composer_bp.{attr} is never set before registration" + ) diff --git a/test/test_composer_empty_block.py b/test/test_composer_empty_block.py index 954d391b..296db78f 100644 --- a/test/test_composer_empty_block.py +++ b/test/test_composer_empty_block.py @@ -78,3 +78,24 @@ def test_template_closes_the_branch_chain_with_a_fallback(): text = TEMPLATE.read_text() assert "{% else %}" in text assert "pass # element type" in text + + +@pytest.mark.parametrize("wrapper", [ + {"minWidth": 64}, + {"blink": True}, + {"minWidth": 64, "blink": True}, +]) +def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper): + """dynamic_text only renders a body for binding.source == 'config'. + + _preprocess_elements accepts any string as binding.source (it just + defaults a missing one to 'config'), so a 'live' or 'sensor' source -- + anything a client sends that isn't literally 'config' -- hit the same + empty-if-block bug as an undrawable element type, just one level deeper: + the branch is taken, but its own inner `if` produced nothing. + """ + element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff", + "binding": {"source": "live", "key": "temperature"}, **wrapper} + files = generate(element) # must not raise ComposerInputError + assert "manager.py" in files + assert 'binding_source "live" draws nothing' in files["manager.py"] diff --git a/web_interface/app.py b/web_interface/app.py index 98bf0f71..3adc2402 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -157,6 +157,7 @@ _health_monitor_initialized = False # Register blueprints from web_interface.blueprints.pages_v3 import pages_v3 from web_interface.blueprints.api_v3 import api_v3 +from web_interface.blueprints.composer import composer_bp # Initialize managers in blueprints pages_v3.config_manager = config_manager @@ -173,6 +174,11 @@ api_v3.operation_queue = operation_queue api_v3.plugin_state_manager = plugin_state_manager api_v3.operation_history = operation_history api_v3.health_monitor = health_monitor + +composer_bp.config_manager = config_manager +composer_bp.plugin_manager = plugin_manager +composer_bp.plugins_dir = plugins_dir +composer_bp.project_root = project_root # Initialize cache manager for API endpoints from src.cache_manager import CacheManager api_v3.cache_manager = CacheManager() @@ -200,6 +206,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard app.register_blueprint(pages_v3, url_prefix='') app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy') app.register_blueprint(api_v3, url_prefix='/api/v3') +app.register_blueprint(composer_bp, url_prefix='/composer') # Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) @app.route('/assets/plugins//uploads/', methods=['GET']) diff --git a/web_interface/templates/v3/composer/manager.py.j2 b/web_interface/templates/v3/composer/manager.py.j2 index 87d9fa9a..c4ff2541 100644 --- a/web_interface/templates/v3/composer/manager.py.j2 +++ b/web_interface/templates/v3/composer/manager.py.j2 @@ -110,6 +110,8 @@ class {{ class_name }}(BasePlugin): {{ pi }} color={{ el.rgb_tuple }}, {{ pi }} font=self.display_manager.{{ el.font_attr }}, {{ pi }}) +{% else %} +{{ pi }}pass # dynamic_text binding_source "{{ el.binding_source }}" draws nothing {% endif %} {% elif el.type == 'clock' %} {{ pi }}self.display_manager.draw_text(