feat: refine trade-pro variant pages (brand/service/faq/search/notfound)
Task 11 (layout port finale): rebuild the five thin variant pages in the sf-tp design language, informed by upstream industrial/automotive 1.3.0/1.4.0 information architecture. - brand: page-hero (breadcrumb Home > Brands > name, cover bg, anchor button) + related-product card grid (fields.brand == page title) + brand profile body only when authored; derived placeholder summary excluded, empty brands get an empty state, never fabricated intros. - service: page-hero + 1.2/0.8 detail grid (article-body typography + sticky CTA card with contact/back links); empty body collapses to a centered solo column with the CTA card kept. - faq: page-hero + article-body for the rich-text body (platform has no faq content type; sanitize strips details/summary so the typography layer carries it, block-driven details coexist via sf-canvas) + dark contact-guidance card; empty body swaps to an empty state. - search: adopts the same platform mechanism as all four upstream themes (site-runtime.js ?q= client search over dist-root search-index.json); #sf-search/#sf-search-results/#sf-search-count hooks and .sf-search-page kept as JS contract, panel and injected result cards restyled via scoped cms-token overrides. - notfound: branded dark-band 404 (icon macro, giant 404 h1, English copy, back-home + browse-products buttons); no end-cta. CSS: Task 11 block appended to pro.css (new sf-tp-* classes only, own 900/620 media queries; shared hero/grid/typography responsive rules come from existing 10a/10c/10d blocks; search overrides beat factory.css by specificity without touching hooks). Tests: test_trade_pro_variant_pages_structure covers brand fixture (related filtering, empty forms), service/faq body-vs-empty, search mechanism contract + noindex + index asset, 404 structure, v3 spot checks (single h1, chrome, SVG arrows) and clean build report. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -798,6 +798,182 @@ def test_trade_pro_standard_layout_structure(factory):
|
||||
assert ssoup.select_one('.sf-tp-page-hero-lead') is not None # bootstrap 摘要在导语位
|
||||
|
||||
|
||||
def test_trade_pro_variant_pages_structure(factory):
|
||||
# Task 11:brand/service/faq/search/notfound 五个次要页面精修(布局移植
|
||||
# 收官)。设计参照:上游四主题 1.3.0/1.4.0 对应页面的信息架构
|
||||
# (automotive 1.4.0 brand 页最完整:横幅 + 面包屑 + 锚点按钮 + 车型
|
||||
# 网格 + 品牌故事;其余为通用信息页骨架)+ 已建立的 sf-tp 设计语言
|
||||
# (page-hero/面包屑/卡片网格/排版层/空态)。
|
||||
# 数据事实(make_context):brand kind 由 related 提供同品牌产品
|
||||
# (fields.brand == 页题);service/faq 仅页面条目上下文(正文富文本,
|
||||
# sanitize 剥离 details/summary——theme.css 的折叠样式只服务块系统注入
|
||||
# 的 custom_blocks,模板正文只能走排版层);search 为构建器生成的
|
||||
# noindex 静态页(site-runtime.js + search-index.json 的 ?q= 客户端检索,
|
||||
# 上游四主题同款机制,#sf-search/#sf-search-results/#sf-search-count
|
||||
# 三钩子是平台 JS 契约);notfound 走 mode='notfound'(noindex meta)。
|
||||
import re
|
||||
sid = site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
||||
# ── 数据夹具:显式品牌(正文/封面)+ 2 同品牌产品 + 1 异品牌产品 +
|
||||
# 服务条目(正文/封面)+ FAQ 页(page_template 显式指定)──
|
||||
made = factory.post(f'/api/sites/{sid}/entries', json={
|
||||
'type': 'brand', 'title': 'Quanta', 'route': '/brands/quanta/',
|
||||
'cover': 'https://imgs.example/quanta-hall.jpg',
|
||||
'summary': 'German engineering brand for flow control systems.',
|
||||
'body': '<p>Quanta manufactures flow control systems for export markets.</p><h2>Heritage</h2><p>Founded as an industrial valve maker.</p>'})
|
||||
assert made.status_code in (200, 201), made.text
|
||||
batch = [
|
||||
{'type': 'product', 'title': 'Quanta Valve V1', 'route': '/product/quanta-valve-v1/',
|
||||
'summary': 'Control valve from Quanta.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Valves'}}},
|
||||
{'type': 'product', 'title': 'Quanta Pump P2', 'route': '/product/quanta-pump-p2/',
|
||||
'summary': 'Centrifugal pump from Quanta.', 'meta': {'fields': {'brand': 'Quanta'}}},
|
||||
{'type': 'product', 'title': 'Rival Actuator A9', 'route': '/product/rival-actuator/',
|
||||
'summary': 'Actuator from another brand.', 'meta': {'fields': {'brand': 'Rival'}}},
|
||||
{'type': 'service', 'title': 'Export Documentation Service', 'route': '/services/export-docs/',
|
||||
'cover': 'https://imgs.example/docs-desk.jpg',
|
||||
'summary': 'Certificates of origin, packing lists and customs paperwork.',
|
||||
'body': '<p>We prepare the complete document set for every shipment.</p><h2>What is included</h2><p>Commercial invoice, packing list and certificate of origin.</p>'},
|
||||
{'type': 'page', 'title': 'Shipping FAQ', 'route': '/faq-shipping/',
|
||||
'summary': 'Frequently asked questions about shipping and documents.',
|
||||
'body': '<p>Answers about lead times and paperwork.</p><h2>How long is the lead time?</h2><p>Standard orders ship in 15 working days.</p>',
|
||||
'meta': {'page_template': 'faq'}},
|
||||
]
|
||||
for row in batch:
|
||||
r = factory.post(f'/api/sites/{sid}/entries', json=row)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
|
||||
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
|
||||
|
||||
# ── v3 契约抽验:五种页面 200 / 唯一 h1 / pro-header + pro-footer ──
|
||||
variant_urls = ['/brands/quanta/', '/services/export-docs/', '/faq-shipping/', '/search/', '/404.html']
|
||||
pages = {}
|
||||
for url in variant_urls:
|
||||
r = factory.get(f'/preview/{sid}/{bid}{url}')
|
||||
assert r.status_code == 200, (url, r.text[:200])
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
assert len(soup.find_all('h1')) == 1, url
|
||||
assert soup.select_one('.pro-header') and soup.select_one('.pro-footer'), url
|
||||
# 交互箭头一律 SVG(Task 9 契约)
|
||||
assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))), url
|
||||
pages[url] = soup
|
||||
|
||||
# ── brand:hero(面包屑 Home › Brands › 品名 + 封面底图 + 锚点按钮)+
|
||||
# 同品牌产品网格(related,异品牌不混入)+ 品牌介绍(正文存在才渲染)──
|
||||
bsoup = pages['/brands/quanta/']
|
||||
hero = bsoup.select_one('section.sf-tp-page-hero.sf-tp-brand-hero')
|
||||
assert hero is not None
|
||||
bg = hero.select_one('.sf-tp-page-hero-bg')
|
||||
assert bg is not None and 'quanta-hall.jpg' in bg.get('src', '')
|
||||
assert hero.select_one('.sf-tp-breadcrumb a[href$="/"]')
|
||||
assert hero.select_one('.sf-tp-breadcrumb a[href$="/brands/"]')
|
||||
assert hero.select_one('.sf-tp-breadcrumb-current').get_text(strip=True) == 'Quanta'
|
||||
assert hero.select_one('.sf-tp-page-hero-copy h1').get_text(strip=True) == 'Quanta'
|
||||
# hero 锚点按钮(automotive 1.4.0 同款信息架构):有产品才渲染
|
||||
assert hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-brand-products"]') is not None
|
||||
# 产品网格:related(fields.brand == 页题),异品牌产品不出现在页面上
|
||||
# (groups 无稳定顺序——断言与插入顺序解耦,只验集合内容)
|
||||
grid = bsoup.select_one('.sf-tp-brand-products')
|
||||
assert grid is not None and grid.get('id') == 'tp-brand-products'
|
||||
cards = grid.select('.sf-tp-card-grid .sf-tp-card')
|
||||
assert len(cards) == 2
|
||||
card_titles = {c.select_one('h3').get_text(strip=True) for c in cards}
|
||||
assert card_titles == {'Quanta Valve V1', 'Quanta Pump P2'}
|
||||
assert 'Rival Actuator' not in grid.get_text()
|
||||
assert cards[0].select_one('.sf-tp-card-media') and cards[0].select_one('a.sf-tp-more svg')
|
||||
assert grid.select_one('.sf-tp-section-head a.sf-tp-more[href$="/products/"] svg')
|
||||
# 品牌介绍:body 存在才渲染(排版层双挂);空品牌不虚构介绍
|
||||
profile = bsoup.select_one('.sf-tp-brand-profile .sf-tp-article-body .pro-rich')
|
||||
assert profile is not None and 'Heritage' in profile.get_text()
|
||||
|
||||
# ── service:hero(面包屑 Home › Services › 标题)+ 正文排版层 + CTA 侧卡 ──
|
||||
ssoup = pages['/services/export-docs/']
|
||||
shero = ssoup.select_one('section.sf-tp-page-hero.sf-tp-service-hero')
|
||||
assert shero is not None
|
||||
sbg = shero.select_one('.sf-tp-page-hero-bg')
|
||||
assert sbg is not None and 'docs-desk.jpg' in sbg.get('src', '')
|
||||
assert shero.select_one('.sf-tp-breadcrumb a[href$="/services/"]')
|
||||
assert shero.select_one('.sf-tp-breadcrumb-current').get_text(strip=True) == 'Export Documentation Service'
|
||||
body = ssoup.select_one('.sf-tp-service-body.sf-tp-article-body .pro-rich')
|
||||
assert body is not None and 'What is included' in body.get_text()
|
||||
aside = ssoup.select_one('.sf-tp-service-aside')
|
||||
assert aside is not None
|
||||
assert aside.select_one('a.sf-tp-btn[href$="/contact/"]') is not None # CTA
|
||||
assert aside.select_one('a[href$="/services/"]') is not None # 返回服务列表
|
||||
|
||||
# ── faq:hero + 正文排版层(平台无 faq 内容类型,正文富文本承担)+
|
||||
# 引导联系卡 ──
|
||||
fsoup = pages['/faq-shipping/']
|
||||
fhero = fsoup.select_one('section.sf-tp-page-hero.sf-tp-faq-hero')
|
||||
assert fhero is not None
|
||||
assert fhero.select_one('.sf-tp-breadcrumb a[href$="/faq/"]')
|
||||
fbody = fsoup.select_one('.sf-tp-faq-body.sf-tp-article-body .pro-rich')
|
||||
assert fbody is not None and 'How long is the lead time?' in fbody.get_text()
|
||||
guide = fsoup.select_one('.sf-tp-faq-guide')
|
||||
assert guide is not None and guide.select_one('a.sf-tp-btn[href$="/contact/"]')
|
||||
|
||||
# ── search:与上游四主题同款平台机制(site-runtime.js ?q= 客户端检索
|
||||
# search-index.json),三钩子契约保留 + noindex + sf-tp 化的搜索面板 ──
|
||||
psoup = pages['/search/']
|
||||
assert 'noindex' in str(psoup.find('meta', attrs={'name': 'robots'}))
|
||||
assert psoup.select_one('main#main-content.sf-search-page')
|
||||
assert psoup.select_one('label[for="sf-search"]')
|
||||
inp = psoup.select_one('input#sf-search[type="search"]')
|
||||
assert inp is not None and inp.get('autofocus') is not None
|
||||
assert psoup.select_one('#sf-search-results') is not None
|
||||
assert psoup.select_one('#sf-search-count[aria-live="polite"]') is not None
|
||||
assert psoup.find('h1').get_text(strip=True) == 'Site Search'
|
||||
# 机制资源随构建分发:site-runtime.js 页面引用 + 检索索引含真实内容
|
||||
# (write_assets 把 search-index.json 写在 dist 根,site-runtime.js 以
|
||||
# <base href="/"> 相对解析——检索 URL 契约)
|
||||
sr = factory.get(f'/preview/{sid}/{bid}/search/')
|
||||
assert 'site-runtime.js' in sr.text
|
||||
idx = factory.get(f'/preview/{sid}/{bid}/search-index.json')
|
||||
assert idx.status_code == 200 and 'Quanta Valve V1' in idx.text
|
||||
# search/notfound 无结尾 CTA 带(pro.html 既有分支)
|
||||
assert 'pro-end-cta' not in sr.text
|
||||
|
||||
# ── notfound:品牌化 404(大标题 + 英文文案 + 双按钮 + SVG 图标)──
|
||||
nsoup = pages['/404.html']
|
||||
assert 'noindex' in str(nsoup.find('meta', attrs={'name': 'robots'}))
|
||||
nsec = nsoup.select_one('.sf-tp-notfound')
|
||||
assert nsec is not None
|
||||
assert nsec.find('h1').get_text(strip=True) == '404'
|
||||
assert nsec.select_one('.icon-circle svg') is not None # icon 宏渲染
|
||||
assert nsec.select_one('a.sf-tp-btn[href$="/"]') is not None # Back to home
|
||||
assert nsec.select_one('a[href$="/products/"]') is not None # Browse products
|
||||
assert 'pro-end-cta' not in str(nsoup)
|
||||
|
||||
# 本构建链接全通(新增面包屑/CTA/锚点不产生断链)
|
||||
report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
|
||||
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
|
||||
|
||||
# ── 空数据形态:空品牌(无产品/无正文)只显空态不虚构介绍;空服务/
|
||||
# 空 FAQ 正文整段隐藏、CTA/引导保留 ──
|
||||
sid2 = site(factory)
|
||||
for row in [
|
||||
{'type': 'brand', 'title': 'Orphan', 'route': '/brands/orphan/'},
|
||||
{'type': 'service', 'title': 'Placeholder Service', 'route': '/services/placeholder/'},
|
||||
{'type': 'page', 'title': 'Bare FAQ', 'route': '/faq-bare/', 'meta': {'page_template': 'faq'}},
|
||||
]:
|
||||
r = factory.post(f'/api/sites/{sid2}/entries', json=row)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
assert factory.put(f'/api/factory/sites/{sid2}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
|
||||
bid2 = factory.post(f'/api/sites/{sid2}/builds', json={}).json()['version']
|
||||
for url, absent, present in [
|
||||
('/brands/orphan/', ['.sf-tp-brand-products', '.sf-tp-brand-profile', '.sf-tp-card'], ['.sf-tp-listing-empty']),
|
||||
('/services/placeholder/', ['.sf-tp-service-body'], ['.sf-tp-service-aside']),
|
||||
('/faq-bare/', ['.sf-tp-faq-body'], ['.sf-tp-faq-guide']),
|
||||
]:
|
||||
r = factory.get(f'/preview/{sid2}/{bid2}{url}')
|
||||
assert r.status_code == 200, (url, r.text[:200])
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
assert len(soup.find_all('h1')) == 1, url
|
||||
for sel in absent:
|
||||
assert soup.select_one(sel) is None, (url, sel)
|
||||
for sel in present:
|
||||
assert soup.select_one(sel) is not None, (url, sel)
|
||||
|
||||
|
||||
def test_trade_pro_chrome_layout(factory):
|
||||
# Task 10j:cms 页眉/页脚 chrome 视觉还原 + 手机端抽屉导航。
|
||||
# 设计源 D:/project/cms/runtimes/astro-site-runtime/src/components/
|
||||
|
||||
Reference in New Issue
Block a user