feat: port cms standard layout into trade-pro

Task 10h (last regular layout). Design sources: cms applications.astro +
support.astro (generic content pages). Common form = PageHero (eyebrow/h1/
lead + in-page anchor button + cover bg) then content area.

- The structured sections in both sources (application card grid,
  FeatureGrid bands, anchor nav strip, doc cards, FAQ) are HESU dictionary
  content, not layout - not ported (fake data across sites). The platform
  standard page content is the rich-text body: section structure is carried
  by the body's own h2/img/tables (10g decision), typeset via the 10d
  sf-tp-article-body layer double-attached with sf-tp-standard-body.
- Empty body hides the body section and the hero anchor button (cms
  isRichAbout ternary); empty cover/summary hide bg/lead independently.
- Breadcrumb Home > title per platform page-level convention (10e/10f);
  support's trailing Cta stays covered by pro.html pro-end-cta.
- Zero new CSS rules: applications.astro itself ships no scoped styles and
  the template is pure composition (10c hero / 10b breadcrumb / 10a
  section-head / theme.css baselines); decision documented in pro.css.
- Test test_trade_pro_standard_layout_structure: build-flow page entry
  asserts hero/body containers; bootstrap /services/ page (meta.
  page_template='standard') verifies the template is not route-bound
  (explicit meta beats route-prefix inference) and the empty-data hiding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 17:25:21 +08:00
parent 0ec826bd4b
commit 87ce104d7e
3 changed files with 150 additions and 2 deletions
+76
View File
@@ -722,6 +722,82 @@ def test_trade_pro_case_layout_structure(factory):
assert bsoup.select_one('.sf-tp-case-location') is None # 字段空则行隐藏
def test_trade_pro_standard_layout_structure(factory):
# Task 10h:standard.html 移植 cms 通用内容页设计(applications.astro +
# support.astro,像素基准 dist/hesupipe/en/{applications,support}/)后的
# 结构契约。standard 是所有非专属 kind 的兜底模板,两页共性形态:
# PageHero(眉题/h1/导语/页内锚点按钮 + 封面底图)→ 内容区。
# 与 cms 的结构决策:两页内容区的应用卡网格 / FeatureGrid / 锚点导航 /
# 文档卡 / FAQ 全是 HESU 词典内容而非布局——跨站即假数据,不移植;
# 平台 standard 页内容 = 富文本 body,小节结构由正文自身 h2 承担
# (10g 案例详情同决策),排版层双挂 sf-tp-article-body(10d)。
# body 为空 → 正文区块与 hero 锚点按钮整段隐藏(cms isRichAbout 三元
# 分支同款)。meta.page_template='standard' 优先于路由前缀推断
# (infer_kind 显式分支先于 /services/ 前缀)——模板不绑路由:
# bootstrap 的 /services/ 页(meta standard)渲染本模板而非 service 模板。
import re
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
made = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'page', 'title': 'Applications', 'route': '/applications/',
'cover': 'https://imgs.example/water-treatment.jpg',
'summary': 'Piping systems for infrastructure, energy, industry and agriculture.',
'body': '<p>Our systems serve municipal, agricultural and industrial projects worldwide.</p><h2>Municipal engineering</h2><p>Reliable piping for urban utility networks.</p>'})
assert made.status_code in (200, 201), made.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']
# ── 主流程:普通页面条目(无 meta.page_template,infer_kind 兜底回落
# standard)→ hero + 正文区块 ──
r = factory.get(f'/preview/{sid}/{bid}/applications/')
assert r.status_code == 200, r.text[:400]
html = r.text
soup = BeautifulSoup(html, 'html.parser')
# hero:面包屑(Home › 标题)+ 封面底图 + 眉题 + 唯一 h1 + 导语
hero = soup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero')
assert hero is not None
bg = hero.select_one('.sf-tp-page-hero-bg')
assert bg is not None and 'water-treatment.jpg' in bg.get('src', '')
assert hero.select_one('.sf-tp-breadcrumb a')
current = hero.select_one('.sf-tp-breadcrumb-current')
assert current is not None and current.get_text(strip=True) == 'Applications'
assert hero.select_one('.sf-tp-page-hero-copy[data-reveal] h1')
assert 'Piping systems for infrastructure' in hero.select_one('.sf-tp-page-hero-lead').get_text()
assert len(soup.find_all('h1')) == 1
# cms PageHero 页内锚点按钮(applications/support 均有):锚到正文区块,
# 箭头为 SVG(Task 9 契约)
btn = hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-standard-body"]')
assert btn is not None and btn.select_one('svg') is not None
# 正文区块:SectionHead(眉题 + 页题 h2——cms about-body 的 SectionHead
# title={heroTitle} 同构)+ 富文本正文(10d 排版层双挂),正文 h2 承担小节
body_section = soup.select_one('section.sf-tp-standard-body-section#tp-standard-body')
assert body_section is not None
head = body_section.select_one('.sf-tp-section-head')
assert head is not None and head.select_one('.eyebrow') and head.find('h2')
body = body_section.select_one('.sf-tp-standard-body.sf-tp-article-body .pro-rich')
assert body is not None and 'Municipal engineering' in body.get_text()
# 旧骨架(Phase 2 pro-info 双栏)无残留
assert 'pro-info-hero' not in html and 'pro-info-section' not in html
# 交互箭头一律 SVG(Task 9 契约)
assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a')))
# 本构建链接全通(面包屑与锚点按钮不产生断链)
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']
# ── 空数据 + 模板不绑路由:bootstrap 的 /services/ 页 meta.page_template
# ='standard'(显式 meta 优先于路由前缀推断),无 body → 正文区块与
# 锚点按钮整段隐藏;渲染 standard 模板而非 service 模板 ──
services = factory.get(f'/preview/{sid}/{bid}/services/')
assert services.status_code == 200, services.text[:300]
ssoup = BeautifulSoup(services.text, 'html.parser')
assert len(ssoup.find_all('h1')) == 1
assert ssoup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero') is not None
assert 'pro-info-hero' not in services.text # service 模板骨架未渲染
assert ssoup.select_one('.sf-tp-standard-body-section') is None # 无 body 整段隐藏
assert '#tp-standard-body' not in services.text # 锚点按钮随正文隐藏
assert ssoup.select_one('.sf-tp-page-hero-bg') is None # 无封面 → 无底图
assert ssoup.select_one('.sf-tp-page-hero-lead') is not None # bootstrap 摘要在导语位
def test_frontend_entries_include_trade_pro():
from pathlib import Path
base = Path(__file__).resolve().parent.parent