"""UI-контракты страницы /recipes: DOM, data-action, API wiring, CSS, mill-ветки.""" from __future__ import annotations import os import re import tempfile import unittest from pathlib import Path from app import create_app, db from app.services.setup_state import mark_setup_complete from config import TestingConfig from tests.helpers.recipes_scenario_matrix import SCENARIO_IDS from tests.helpers.recipes_ui_inventory import ( ACTION_COVERAGE_TARGET, API_ENDPOINTS, CSS_ANIMATION_MARKERS, DATA_ACTION_HANDLERS, DOM_IDS, MILL_MARKERS, PROJECT_ROOT, RECIPES_PAGE_NESTED_MODULES, RECIPES_PAGE_SCRIPTS, SKELETON_TEMPLATE_IDS, STATIC, ) class RecipesUiContractConfig(TestingConfig): _TMP_DIR = tempfile.mkdtemp(prefix="wesp-recipes-ui-contract-") SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}" SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"} AUTH_LOGIN = "recipes-ui-admin" AUTH_PASSWORD = "recipes-ui-secret" class RecipesUiContractTests(unittest.TestCase): def setUp(self) -> None: self.app = create_app(RecipesUiContractConfig) self.ctx = self.app.app_context() self.ctx.push() db.create_all() mark_setup_complete(self.app) self.html = (STATIC / "recipes.html").read_text(encoding="utf-8") def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def _read(self, rel_path: str) -> str: return (PROJECT_ROOT / rel_path).read_text(encoding="utf-8") def test_dom_ids_present_in_recipes_html(self) -> None: for dom_id in DOM_IDS: with self.subTest(dom_id=dom_id): self.assertIn(f'id="{dom_id}"', self.html) def test_skeleton_templates_present(self) -> None: for tpl_id in SKELETON_TEMPLATE_IDS: with self.subTest(template=tpl_id): self.assertIn(f'id="{tpl_id}"', self.html) def test_data_actions_have_handlers(self) -> None: for action, handler_files in DATA_ACTION_HANDLERS.items(): with self.subTest(action=action): found = False for rel in handler_files: content = self._read(rel) if f'case "{action}"' in content or f"action === \"{action}\"" in content: found = True break if f'data-action="{action}"' in content and "handleAction" in content: found = True break self.assertTrue(found, f"handler for {action} not found in {handler_files}") def test_api_endpoints_referenced_in_js(self) -> None: for endpoint, files in API_ENDPOINTS: with self.subTest(endpoint=endpoint): found = any(endpoint in self._read(rel) for rel in files) self.assertTrue(found, f"{endpoint} not in {files}") def test_api_routes_registered(self) -> None: rules = {rule.rule for rule in self.app.url_map.iter_rules()} expected = [ "/api/feed_dispensers", "/api/recipes/", "/api/auth/login", "/api/auth/check", "/api/sync/clients", "/recipes", ] for path in expected: with self.subTest(path=path): self.assertTrue( any(r == path or "<" in r and path.split("<")[0] in r for r in rules), f"missing route like {path}, have {sorted(rules)!r}", ) def test_save_payload_includes_deleted_arrays_and_ingredient_ids(self) -> None: self.assertIn("deleted_ingredient_ids: deletedIngredientIds", self.html) self.assertIn("deleted_unloading_group_ids: deletedGroupIds", self.html) self.assertIn("deletedIngredientIds.push(row.dataset.id)", self.html) self.assertIn("async function getIngredientsData", self.html) def test_css_animation_markers_exist(self) -> None: for marker, files in CSS_ANIMATION_MARKERS: with self.subTest(marker=marker): found = any(marker in self._read(rel) for rel in files) self.assertTrue(found) def test_mill_branch_markers(self) -> None: for marker, files in MILL_MARKERS: with self.subTest(marker=marker): found = any(marker in self._read(rel) for rel in files) self.assertTrue(found) def test_recipes_page_scripts_referenced(self) -> None: for script in RECIPES_PAGE_SCRIPTS: with self.subTest(script=script): self.assertIn(script, self.html) def test_recipes_page_nested_modules_via_page_js(self) -> None: page_js = self._read("static/js/pages/recipes-page.js") for script in RECIPES_PAGE_NESTED_MODULES: with self.subTest(script=script): self.assertIn(script, page_js) def test_editor_buttons_data_actions_in_html(self) -> None: editor_actions = [ "add-ingredient", "add-unloading-group", "save-and-exit", "toggle-unloading-link", "remove-ingredient", "show-recipe-edit-off", ] for action in editor_actions: with self.subTest(action=action): self.assertIn(f'data-action="{action}"', self.html) def test_dispenser_selector_actions_in_controller(self) -> None: js = self._read("static/js/modules/recipes/dispenser-selector.js") for action in ( "select-dispenser", "select-recipe", "delete-recipe", "unskip-recipe-today", "create-recipe", "move-recipe-up", ): with self.subTest(action=action): self.assertIn(f'case "{action}"', js) def test_unskip_recipe_wired_in_recipes_page(self) -> None: page_js = self._read("static/js/pages/recipes-page.js") self.assertIn("unskipRecipeToday: handlers.unskipRecipeToday", page_js) def test_recipe_editor_shows_part_skip_in_recipes_list(self) -> None: controller_js = self._read("static/js/pages/recipes-data-controller.js") self.assertIn("hasSkipToday", controller_js) self.assertIn("dashboard-skip-warning", controller_js) self.assertIn("unskip-ingredient-parts-today", controller_js) html = self._read("static/recipes.html") self.assertIn("recipe-row-skip-dot", html) self.assertIn("recipe-row-name-with-skip", html) panel_js = self._read("static/js/pages/daily-plan-panel.js") self.assertIn("zt-k-hub-plan-row__skip-dot", panel_js) def test_sync_panel_actions_in_controller(self) -> None: js = self._read("static/js/modules/recipes/sync-panel.js") for action in ("open-settings", "toggle-sync-form", "change-credentials"): with self.subTest(action=action): self.assertIn(f'case "{action}"', js) def test_mill_delete_uses_global_recipes_endpoint(self) -> None: js = self._read("static/js/pages/recipes-operations-controller.js") self.assertRegex( js, r"unlinkFromPeriodOnly\s*=\s*Boolean\(periodId\s*&&\s*dispenserId\s*&&\s*!isMill\)", ) self.assertIn("/api/recipes/", js) def test_recipe_editor_save_debounce_present(self) -> None: self.assertIn("SAVE_DEBOUNCE_MS", self.html) self.assertIn("doSaveRecipe", self.html) def test_mobile_dashboard_steps_in_data_controller(self) -> None: js = self._read("static/js/pages/recipes-data-controller.js") self.assertIn("dashboard-mobile-step-", js) self.assertIn("MOBILE_DASHBOARD_STEPS", js) def test_flip_reorder_constant_documented(self) -> None: js = self._read("static/js/pages/recipes-data-controller.js") self.assertIn("RECIPES_REORDER_FLIP_MS", js) def test_get_recipe_serializes_unloading_link_broken(self) -> None: recipes_py = self._read("app/routes/recipes.py") self.assertIn('"unloading_link_broken": recipe.unloading_link_broken', recipes_py) editor_js = self._read("static/js/pages/recipes-editor-controller.js") self.assertIn("recipe.unloading_link_broken", editor_js) def test_get_recipe_serializes_unloading_groups_snake_case(self) -> None: recipes_py = self._read("app/routes/recipes.py") self.assertIn('"unloading_groups":', recipes_py) self.assertIn('"distribution_type": g.distribution_type', recipes_py) ops_js = self._read("static/js/pages/recipes-operations-controller.js") self.assertIn("group.distribution_type ?? group.distributionType", ops_js) def test_recipe_js_modules_have_cache_bust_query(self) -> None: self.assertIn("recipe-list-dnd.js?v=", self.html) self.assertIn("recipes-operations-controller.js?v=", self.html) def test_heads_unloading_skips_proportional_rescale(self) -> None: html = self._read("static/recipes.html") self.assertIn("hasHeadsGroups", html) self.assertIn("!hasHeadsGroups", html) def test_recipes_html_includes_notification_center_scripts(self) -> None: self.assertIn("wesp-zootech-notification-messages.js", self.html) self.assertIn("wesp-zootech-notification-center.js", self.html) self.assertIn("wesp-zootech-notify.js", self.html) def test_notification_center_js_exports(self) -> None: center_js = self._read("static/js/wesp-zootech-notification-center.js") notify_js = self._read("static/js/wesp-zootech-notify.js") self.assertIn("wesp-zt-notify-fab", center_js) self.assertIn("open-notification-center", center_js) self.assertIn("persistNotification", notify_js) def test_zootech_nav_uses_same_logo_asset_in_both_themes(self) -> None: nav_js = self._read("static/js/wesp-zootech-nav.js") shimmer_js = self._read("static/js/wesp-nav-logo-shimmer.js") self.assertIn('LOGO_LIGHT = "/static/logo2.png"', nav_js) self.assertIn('LOGO_DARK = "/static/logo2.png"', nav_js) self.assertNotIn('LOGO_DARK = "/static/logo.png"', nav_js) self.assertIn('LOGO_SRC = "/static/logo2.png"', shimmer_js) def test_reorder_active_does_not_cancel_pointer_drag(self) -> None: transfer = self._read("static/js/modules/recipes/recipe-period-transfer.js") dnd = self._read("static/js/modules/recipes/recipe-list-dnd.js") self.assertIn("clearTransferSessionState", transfer) self.assertRegex( transfer, r'recipe-reorder-active[\s\S]*?clearTransferSessionState\(\{\s*emitDragCancel:\s*false', ) self.assertIn("releaseTransferPointerCapture", transfer) self.assertIn("Desktop: reorder владеет ручкой", transfer) self.assertIn("pendingDragItem", transfer) self.assertIn("if (pointerDrag.active) return;", dnd) self.assertIn( 'listRoot?.classList.contains("recipe-list-reorder-active")', dnd, ) def test_scenario_matrix_has_test(self) -> None: tests_root = PROJECT_ROOT / "tests" corpus = "" for path in sorted(tests_root.rglob("test_*.py")): corpus += path.read_text(encoding="utf-8") + "\n" for scenario_id in SCENARIO_IDS: with self.subTest(scenario=scenario_id): self.assertIn( scenario_id, corpus, f"scenario {scenario_id} not referenced in tests/", ) def test_all_actions_have_coverage_entry(self) -> None: for action in DATA_ACTION_HANDLERS: with self.subTest(action=action): self.assertIn(action, ACTION_COVERAGE_TARGET) self.assertIn( ACTION_COVERAGE_TARGET[action], ("e2e", "api", "contract"), ) for action in ACTION_COVERAGE_TARGET: with self.subTest(orphan=action): self.assertIn(action, DATA_ACTION_HANDLERS) if __name__ == "__main__": unittest.main()