Files
what-to-eat/scripts/qa/recipe-views-mobile.py
T

154 lines
8.5 KiB
Python

"""Verify classic / matching views and persisted settings on a touch viewport.
python scripts/qa/recipe-views-mobile.py http://127.0.0.1:3101
Requires Python Playwright and Google Chrome.
"""
from datetime import datetime
import json
from pathlib import Path
import sys
from zoneinfo import ZoneInfo
from playwright.sync_api import expect, sync_playwright
from recipe_view_helpers import choose_home_view, expect_home_view
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:3101'
OUTPUT = Path(__file__).resolve().parents[2] / 'artifacts' / 'recommendations'
OUTPUT.mkdir(parents=True, exist_ok=True)
report = {'tested_at': datetime.now(ZoneInfo('Asia/Shanghai')).isoformat(timespec='seconds'), 'checks': [], 'errors': []}
def screenshot(page, name):
page.wait_for_timeout(300)
page.screenshot(path=str(OUTPUT / name))
with sync_playwright() as p:
browser = p.chromium.launch(channel='chrome', headless=True)
report['browser'] = browser.version
context = browser.new_context(viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True, device_scale_factor=1)
page = context.new_page()
page.on('pageerror', lambda error: report['errors'].append(str(error)))
page.goto(BASE_URL)
page.wait_for_load_state('networkidle')
expect_home_view(page, '经典视图')
expect(page.locator('.ingredient-basket')).to_have_count(0)
expect(page.locator('.pantry-settings')).to_have_count(0)
expect(page.get_by_role('button', name='模糊匹配', exact=True)).to_be_visible()
expect(page.get_by_role('button', name='打开食材篮操作', exact=True)).to_be_visible()
screenshot(page, 'mobile-classic.png')
picker = page.locator('ion-select.recipe-view-menu:visible')
picker_box = picker.bounding_box()
title_box = page.locator('.home-reset:visible').bounding_box()
assert picker_box['width'] >= 44 and picker_box['height'] >= 44, picker_box
assert title_box['x'] + title_box['width'] <= picker_box['x'], (title_box, picker_box)
assert page.get_by_role('heading', name='🥘 先选一下食材').bounding_box()['y'] < 100
expect(page.locator('.choose-food .recipe-view-switch')).to_have_count(0)
picker.tap()
expect(page.get_by_role('radio', name='经典视图', exact=True)).to_be_checked()
expect(page.locator('ion-popover')).to_contain_text('选择会自动记住')
screenshot(page, 'mobile-view-menu.png')
page.touchscreen.tap(8, 350)
expect(page.locator('ion-popover')).to_have_count(0)
expect_home_view(page, '经典视图')
picker_button = page.get_by_role('button', name='做菜视图, 经典视图', exact=True)
expect(picker_button).to_be_focused()
page.keyboard.press('Enter')
expect(page.get_by_role('radio', name='经典视图', exact=True)).to_be_visible()
page.keyboard.press('Escape')
expect(page.locator('ion-popover')).to_have_count(0)
expect(picker_button).to_be_focused()
report['checks'].append({'compact_toolbar_picker': picker_box, 'checks': 'No added content row; selected option and hint; tap outside / Escape close; focus restored; Enter opens'})
# Classic still allows kitchen-tool-only search.
page.get_by_role('button', name='电饭煲', exact=True).tap()
expect(page.locator('.classic-recipe-panel .dish-tag').first).to_be_visible()
tool_only_count = page.locator('.classic-recipe-panel .dish-tag').count()
assert tool_only_count > 0
page.get_by_role('button', name='电饭煲', exact=True).tap()
page.locator('button.vegetable-tag').filter(has_text='土豆').tap()
expect(page.locator('.classic-recipe-panel .dish-tag').first).to_be_visible()
classic_count = page.locator('.classic-recipe-panel .dish-tag').count()
assert classic_count > 79 # Matching view excludes more than two missing ingredients.
page.get_by_role('button', name='打开食材篮操作', exact=True).tap()
page.get_by_role('button', name='查看经典菜谱', exact=True).tap()
page.locator('.classic-recipe-panel').evaluate("el => el.scrollIntoView({block: 'start'})")
screenshot(page, 'mobile-classic-results.png')
report['checks'].append({'classic_tool_only_count': tool_only_count, 'classic_potato_count': classic_count})
choose_home_view(page, '匹配视图')
expect(page.get_by_role('button', name='移除土豆', exact=True)).to_be_visible()
expect(page.get_by_role('button', name='食材齐全 30 道', exact=True)).to_be_visible()
page.reload()
page.wait_for_load_state('networkidle')
expect_home_view(page, '匹配视图')
expect(page.get_by_role('button', name='移除土豆', exact=True)).to_be_visible()
report['checks'].append('Home switch retains selection and remembers matching view after reload')
# Change the preference via the actual settings route.
page.goto(BASE_URL + '/settings')
page.wait_for_load_state('networkidle')
expect(page.get_by_role('button', name='匹配视图', exact=True)).to_have_attribute('aria-pressed', 'true')
page.get_by_role('button', name='经典视图', exact=True).tap()
screenshot(page, 'mobile-view-settings.png')
page.goto(BASE_URL)
page.wait_for_load_state('networkidle')
expect_home_view(page, '经典视图')
expect(page.locator('button.vegetable-tag').filter(has_text='土豆')).to_have_attribute('aria-pressed', 'true')
page.reload()
page.wait_for_load_state('networkidle')
expect_home_view(page, '经典视图')
report['checks'].append('Settings changes home view; classic view and ingredients survive reload')
# Also test Ionic route caching: navigation back must update the already-mounted home.
page.get_by_role('tab', name='我的', exact=True).tap()
page.get_by_text('设置', exact=True).tap()
page.get_by_role('button', name='匹配视图', exact=True).tap()
page.locator('ion-back-button').tap()
page.get_by_role('tab', name='做菜', exact=True).tap()
expect_home_view(page, '匹配视图')
expect(page.get_by_role('button', name='移除土豆', exact=True)).to_be_visible()
report['checks'].append('Settings selection also updates the cached Ionic home via back/tab navigation')
page.set_viewport_size({'width': 320, 'height': 740})
choose_home_view(page, '经典视图')
assert page.evaluate('document.body.scrollWidth <= innerWidth')
assert page.locator('ion-title:visible').evaluate("e => { const title = e.shadowRoot.querySelector('.toolbar-title'); return title.scrollWidth <= title.clientWidth }")
title_box = page.locator('.home-reset:visible').bounding_box()
picker_box = page.locator('ion-select.recipe-view-menu:visible').bounding_box()
assert title_box['x'] + title_box['width'] <= picker_box['x'], (title_box, picker_box)
screenshot(page, 'mobile-classic-320.png')
page.locator('ion-select.recipe-view-menu:visible').tap()
expect(page.get_by_role('radio', name='匹配视图', exact=True)).to_be_visible()
screenshot(page, 'mobile-view-menu-320.png')
menu_box = page.locator('ion-select-popover').bounding_box()
assert 0 <= menu_box['x'] and menu_box['x'] + menu_box['width'] <= 320, menu_box
page.keyboard.press('Escape')
report['checks'].append('Classic layout and switch fit a 320 px viewport')
context.close()
# Emulate an installed user's old settings: absence of recipeView must preserve classic.
legacy = browser.new_context(viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True)
legacy.add_init_script("if (!localStorage.getItem('cook:settings')) localStorage.setItem('cook:settings', JSON.stringify({keepLocalData: false}))")
old_page = legacy.new_page()
old_page.on('pageerror', lambda error: report['errors'].append(str(error)))
old_page.goto(BASE_URL)
old_page.wait_for_load_state('networkidle')
expect_home_view(old_page, '经典视图')
choose_home_view(old_page, '匹配视图')
old_page.locator('button.vegetable-tag').filter(has_text='番茄').tap()
old_page.reload()
old_page.wait_for_load_state('networkidle')
expect_home_view(old_page, '匹配视图')
expect(old_page.locator('.basket-item')).to_have_count(0)
assert old_page.evaluate("JSON.parse(localStorage.getItem('cook:settings')).keepLocalData") is False
report['checks'].append('Old settings migrate to classic; disabling ingredient persistence still remembers the chosen view')
legacy.close()
browser.close()
assert not report['errors'], report['errors']
report['passed'] = True
(OUTPUT / 'view-validation.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
print(json.dumps(report, ensure_ascii=False, indent=2))