Files
site/WESP_REL/tests/test_recipes_ui_contract.py
2026-07-17 12:57:18 +03:00

286 lines
12 KiB
Python

"""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/<recipe_id>",
"/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()