@@ -0,0 +1,285 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user