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

226 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Touch-browser acceptance for Cook. Run against a built/static server.
Usage: python scripts/qa/recommendations-mobile.py http://127.0.0.1:3101
Requires: Python Playwright and Google Chrome (or PLAYWRIGHT_CHANNEL).
Screenshots and machine-readable results go to artifacts/recommendations/.
"""
from datetime import datetime
import json
import os
from pathlib import Path
import re
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)
CASES = [
(['番茄', '鸡蛋'], [40, 72, 48], '微波炉版番茄鸡蛋汤', ['米'], ['面食', '香肠']),
(['土豆'], [30, 30, 19], '烤箱版M记同款薯条', ['鸡肉'], ['香肠', '米']),
(['腊肠', '米'], [6, 30, 26], '电饭煲版广式腊肠煲饭', ['胡萝卜'], ['猪肉', '土豆']),
(['鸡肉', '洋葱', '菌菇'], [68, 34, 25], '电饭煲版烧鸡', ['米'], ['胡萝卜', '土豆']),
(['牛肉', '番茄', '米'], [15, 55, 50], '电饭煲版番茄牛腩焖饭', ['猪肉'], ['土豆', '胡萝卜']),
]
LABELS = ['食材齐全', '还差一种', '还差两种']
report = {'tested_at': datetime.now(ZoneInfo('Asia/Shanghai')).isoformat(timespec='seconds'), 'url': BASE_URL, 'mobile': '390 × 844, touch, DPR 1', 'cases': [], 'checks': [], 'errors': []}
def see_results(page):
edit = page.get_by_role('button', name=re.compile('调整食材'))
if edit.count():
edit.tap()
expect(page.get_by_role('button', name=re.compile('看推荐'))).to_be_visible()
page.get_by_role('button', name=re.compile('看推荐')).tap()
page.wait_for_function("document.querySelector('.recipe-panel').getBoundingClientRect().top < innerHeight - 200")
def capture(page, name):
# Wait for Ionic's smooth scrolling to settle before capturing the actual viewport.
page.wait_for_timeout(400)
page.screenshot(path=str(OUTPUT / name))
def add(page, ingredients):
page.get_by_label('补充食材', exact=True).fill('、'.join(ingredients))
page.get_by_role('button', name='加入', exact=True).tap()
def clear(page):
page.get_by_role('button', name='清空食材', exact=True).tap()
def check_counts(page, counts):
for label, count in zip(LABELS, counts):
expect(page.get_by_role('button', name=f'{label} {count} 道', exact=True)).to_be_visible()
def no_overflow(page):
sizes = page.evaluate('''() => ({ viewport: innerWidth, body: document.body.scrollWidth,
content: document.querySelector('.choose-food').scrollWidth })''')
assert sizes['body'] <= sizes['viewport'] and sizes['content'] <= sizes['viewport'], sizes
assert page.locator('ion-title').evaluate("e => { const title = e.shadowRoot.querySelector('.toolbar-title'); return title.scrollWidth <= title.clientWidth }"), 'Header title is clipped'
with sync_playwright() as p:
browser = p.chromium.launch(channel=os.environ.get('PLAYWRIGHT_CHANNEL', 'chrome'), headless=True)
report['browser'] = f'Chrome {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)))
print('Opening mobile page', flush=True)
page.goto(BASE_URL)
page.wait_for_load_state('networkidle')
expect_home_view(page, '经典视图')
choose_home_view(page, '匹配视图')
expect(page.get_by_text('先往食材篮里放点东西吧')).to_be_attached()
see_results(page)
capture(page, 'mobile-empty.png')
report['checks'].append('Fresh visit defaults to classic; after switching to matching view: database initialized, empty basket prompt, example action available')
print('Empty state passed', flush=True)
for index, (ingredients, counts, first, missing_one, missing_two) in enumerate(CASES):
print('Checking', ingredients, flush=True)
if index:
clear(page)
if index == 0:
page.locator('button.vegetable-tag').filter(has_text='番茄').tap()
page.locator('button.meat-tag').filter(has_text='鸡蛋').tap()
page.locator('.ingredient-basket').scroll_into_view_if_needed()
capture(page, 'mobile-selection.png')
else:
add(page, ingredients)
see_results(page)
check_counts(page, counts)
expect(page.locator('.match-card').first).to_have_attribute('aria-label', first)
expect(page.locator('.match-card').first).to_contain_text('无,已记录食材齐全')
expect(page.locator('.match-card')).to_have_count(6)
if index == 0:
capture(page, 'mobile-complete.png')
for group, missing in [(1, missing_one), (2, missing_two)]:
page.get_by_role('button', name=f'{LABELS[group]} {counts[group]} 道', exact=True).tap()
expect(page.locator('.match-card').first.locator('.is-missing dd')).to_have_text('、'.join(missing))
if index == 0:
capture(page, f'mobile-missing-{group}.png')
no_overflow(page)
report['cases'].append({'ingredients': ingredients, 'counts': counts, 'first': first, 'missing_one': missing_one, 'missing_two': missing_two, 'passed': True})
clear(page)
add(page, ['西红柿', '番茄', '鸡蛋', '鸡蛋'])
expect(page.locator('.basket-item')).to_have_count(2)
add(page, ['西红柿', '鸡蛋'])
expect(page.get_by_text('这些食材已经在篮子里了,不用重复添加。')).to_be_visible()
see_results(page)
check_counts(page, [40, 72, 48])
page.get_by_role('button', name=re.compile('再看 6 道')).tap()
expect(page.locator('.match-card')).to_have_count(12)
report['checks'].append('Aliases / duplicates share one basket item and keep identical counts; pagination 6 → 12')
page.locator('.pantry-summary').tap()
page.get_by_role('button', name='全部取消', exact=True).tap()
expect(page.locator('.pantry-option[aria-pressed="true"]')).to_have_count(0)
page.locator('.pantry-option').filter(has_text='盐').tap()
page.locator('.pantry-option').filter(has_text='蒜').tap()
expect(page.locator('.pantry-option[aria-pressed="true"]')).to_have_count(2)
page.locator('.pantry-settings').evaluate("el => el.scrollIntoView({block: 'start'})")
capture(page, 'mobile-pantry.png')
page.reload()
page.wait_for_load_state('networkidle')
expect(page.locator('.basket-item')).to_have_count(2)
page.locator('.pantry-summary').tap()
expect(page.locator('.pantry-option[aria-pressed="true"]')).to_have_text(['✓ 盐', '✓ 蒜'])
page.get_by_role('button', name='恢复油、盐', exact=True).tap()
expect(page.locator('.pantry-option[aria-pressed="true"]')).to_have_text(['✓ 油', '✓ 盐'])
see_results(page)
check_counts(page, [40, 72, 48])
report['checks'].append('Pantry toggles, cancel all, restore defaults and refresh persistence work; absent condiments never change counts')
page.locator('.filter-summary').tap()
page.get_by_label('筛选菜名').fill('不存在的菜谱')
expect(page.get_by_text('暂时没有符合条件的菜谱', exact=True)).to_be_visible()
page.get_by_role('button', name='清除筛选,保留食材', exact=True).tap()
check_counts(page, [40, 72, 48])
page.get_by_label('匹配范围').select_option('survival')
check_counts(page, [40, 0, 0])
page.get_by_role('button', name='还差一种 0 道', exact=True).tap()
expect(page.get_by_text('这个分组暂时没有菜谱,试试其他分组或调整食材。')).to_be_visible()
page.get_by_label('匹配范围').select_option('strict')
assert int(page.locator('.match-group strong').first.inner_text().split()[0]) > 0
first_card = page.locator('.match-card').first
expect(first_card.locator('.match-details-body')).to_be_hidden()
first_card.locator('.match-details-toggle').tap()
expect(first_card.locator('.match-details-body')).to_be_visible()
expect(first_card.locator('.match-details-body')).to_contain_text('用上 2 种已选食材')
first_card.locator('.match-details-toggle').tap()
expect(first_card.locator('.match-details-body')).to_be_hidden()
report['checks'].append('Recipe tags link to the recorded video; touch expands and collapses the full matching basis while missing ingredients stay visible')
page.get_by_role('button', name='清除筛选', exact=True).tap()
page.get_by_role('button', name='微波炉', exact=True).tap()
see_results(page)
assert page.locator('.match-card').count() > 0
for text in page.locator('.match-tools').all_text_contents():
assert '微波炉' in text
page.get_by_role('button', name='清除筛选', exact=True).tap()
report['checks'].append('Keyword, empty filtered state, clear filters preserving basket, strict/survival modes, empty group and kitchen tool filtering work')
# Exercise the existing recipe-link flow without loading an external video.
context.route('https://www.bilibili.com/**', lambda route: route.fulfill(status=200, body='<p>Recipe link destination verified</p>'))
with page.expect_popup() as popup_info:
page.locator('.match-card').first.get_by_role('link').tap()
popup = popup_info.value
popup.wait_for_load_state()
assert 'bilibili.com/video/BV' in popup.url
popup.close()
report['checks'].append('Recipe title opens its recorded video link in a new window')
page.get_by_role('button', name='移除番茄', exact=True).tap()
expect(page.locator('.basket-item')).to_have_count(1)
clear(page)
add(page, ['火龙果'])
see_results(page)
expect(page.get_by_text('暂时没有符合条件的菜谱', exact=True)).to_be_visible()
check_counts(page, [0, 0, 0])
capture(page, 'mobile-no-match.png')
clear(page)
see_results(page)
page.get_by_role('button', name='试试番茄 + 鸡蛋', exact=True).tap()
check_counts(page, [40, 72, 48])
page.locator('.ranking-help summary').tap()
expect(page.get_by_text(re.compile('优先食材齐全,再看还差一种'))).to_be_visible()
report['checks'].append('Individual removal, full clear, unmatched ingredient, example recovery and visible ranking explanation work')
page.set_viewport_size({'width': 320, 'height': 740})
see_results(page)
no_overflow(page)
page.locator('.ranking-help summary').tap()
capture(page, 'mobile-320.png')
page.set_viewport_size({'width': 1280, 'height': 900})
see_results(page)
no_overflow(page)
capture(page, 'desktop.png')
report['checks'].append('320 px narrow phone and 1280 px desktop: no horizontal overflow')
dark_context = browser.new_context(viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True, color_scheme='dark', device_scale_factor=1)
dark_page = dark_context.new_page()
dark_page.on('pageerror', lambda error: report['errors'].append(str(error)))
dark_page.goto(BASE_URL)
dark_page.wait_for_load_state('networkidle')
choose_home_view(dark_page, '匹配视图')
add(dark_page, ['番茄', '鸡蛋'])
see_results(dark_page)
expect(dark_page.locator('html')).to_have_class(re.compile('dark'))
capture(dark_page, 'mobile-dark.png')
no_overflow(dark_page)
report['checks'].append('System dark theme renders the same touch interface without overflow')
dark_context.close()
context.close()
browser.close()
assert not report['errors'], report['errors']
report['passed'] = True
(OUTPUT / 'mobile-validation.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
print(json.dumps(report, ensure_ascii=False, indent=2))