Task 15: product detail anchor tabs + per-product custom sections

User report: product detail page was wrong — section titles are
per-product custom data (CMS wppop_sections migration: Packing &
Delivery / Our Exhibition / Our Certifications / Our Company / FAQ),
not fixed headings; they must drive an anchor tab system like the
CMS [locale]/products/[slug].astro detailTabs.

Data side (both layers, task option 1 + 2):

- theme_pro.product_detail_sections: fields.sections
  [{title, content}] JSON is the structured source; legacy sync
  output that appended sections as trailing body H2 blocks is
  recognized by known/data titles and split out (overview keeps the
  rest; same-title blocks dedupe to the structured version; Features
  body block drops when fields.features renders it)
- sync_cms_data.build_product: store sections/features as structured
  fields JSON, stop appending H2 blocks to the body; stats now count
  entries with a sections field
- Specifications table no longer leaks structural JSON field keys
  (sections/features/specs/gallery/categories) — scalar spec keys
  only

Template side (trade-pro product.html, replaces 12a panel
switching):

- tab sequence is fully data-driven and matches the CMS: Product
  Details (always) -> custom sections (data titles) ->
  Specifications / Features (shown only when data exists)
- tabs are #anchor links, not panel toggles: all sections render
  flat with visible titles (readable and crawlable without JS; native
  anchor jump), JS adds smooth scrolling + scrollspy highlight +
  tab centering in the horizontal scroll row
- section ids are slug-derived (sec-packing-delivery), duplicate
  titles get an index suffix; features grid ports the CMS
  detail-feature-row

Tests: test_trade_pro_product_layout_structure rewritten for the
anchor-tab contract — sections via fields, legacy body-H2 fallback,
spec/features conditional tabs, no-sections product showing only
Product Details + Specifications, spec table free of JSON key leaks,
updated pro.css needles. 30 passed; the 1 failure
(test_trade_pro_blocks_visual_bridge) fails identically on the
previous commit (pre-existing, unrelated).

Co-Authored-By: Claude Code <noreply@example.com>
This commit is contained in:
浅念
2026-10-29 13:49:08 +08:00
parent 3ce4292794
commit f5dc2f7624
5 changed files with 311 additions and 84 deletions
+90 -35
View File
@@ -182,25 +182,41 @@ def test_trade_pro_home_layout_structure(factory):
def test_trade_pro_product_layout_structure(factory):
# Task 12a:product.html 按 cms [slug].astro 精确对齐重做后的结构契约。
# 构建流(/preview/ HTTP)+ 带 fields/gallery 的产品条目驱动断言:
# hero 画廊(pro.js 钩子)+ 标题区(cms 产品页无 h1,产品名 h2 量级);
# sticky 选项卡导航 + 面板(overview 恒有 / specifications 有数据才显);
# 无 JS 平铺回退(静态标记不带 tabs-enabled、面板无内联隐藏);
# rich-body 正文表格样式 + spec-table 两对键值一行 + 面板标题品牌色下划短线;
# 询盘/相关产品沿用既有实现;旧骨架 .pro-product-layout/.pro-specs 无残留。
# Task 15:product.html 锚点选项卡 + 每产品自定义区块(重做 12a 面板切换)。
# Tab 序列对齐 cms detailTabs:Product Details(正文)→ 自定义区块
# (wppop_sections 迁移,标题来自每产品数据不固定)→ Specifications →
# Features(后两者按数据显隐)。Tab 为锚点跳转非面板切换:区块全部平铺
# 渲染、panel-title 常显(无 JS 原生锚点跳转可读、静态可抓取);JS 渐进
# 增强平滑滚动 + scrollspy 高亮。区块数据双来源:fields.sections 结构化
# 优先;旧同步产物 body 尾部 H2 段按标题识别拆出(正文不重复渲染)。
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': 'product', 'title': 'Hydraulic Pump HP-200', 'route': '/product/hp-200/',
'cover': 'https://imgs.example/hp-200.jpg', 'summary': 'High-pressure hydraulic pump for export.',
'body': '<p>HP-200 body copy with export-grade tolerances.</p>',
'meta': {'fields': {'brand': 'Quanta', 'range': '500 km', 'pressure': '210 bar',
'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg']}}})
'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg'],
'sections': json.dumps([
{'title': 'Packing & Delivery', 'content': '<p>Export grade packing on fumigated pallets.</p>'},
{'title': 'FAQ', 'content': '<p>Lead time is 20 days after deposit.</p>'}]),
'features': json.dumps([
{'title': 'High pressure', 'desc': '210 bar continuous rating.'},
{'title': 'Export packing', 'desc': 'Fumigated pallets and desiccant.'}])}}})
assert made.status_code in (200, 201), made.text
other = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'product', 'title': 'Companion Valve', 'route': '/product/valve/',
'summary': 'Matching control valve.'})
'summary': 'Matching control valve.',
'meta': {'fields': {'material': 'Stainless steel'}}})
assert other.status_code in (200, 201), other.text
# 旧同步产物形态:区块以 H2 段追加在 body 尾部(无 fields.sections)
legacy = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'product', 'title': 'Drainage Pipe DN200', 'route': '/product/dn200/',
'summary': 'Corrugated drainage pipe.',
'body': '<p>DN200 intro paragraph stays in overview.</p>'
'<h2>Packing & Delivery</h2><p>Bundled in 6 m lengths.</p>'
'<h2>Our Exhibition</h2><p>Canton Fair booth photos.</p>'})
assert legacy.status_code in (200, 201), legacy.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']
r = factory.get(f'/preview/{sid}/{bid}/product/hp-200/')
@@ -217,30 +233,40 @@ def test_trade_pro_product_layout_structure(factory):
assert soup.select_one('.sf-tp-product-summary h1.sf-tp-product-title')
assert len(soup.find_all('h1')) == 1 # 平台单 h1 契约(与 test_theme_pro_v3 一致)
assert soup.select_one('.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]')
# 选项卡导航:sticky 容器 + 单行横滚 tabs-row;首个 active,第二个有 specs 才显
# 锚点选项卡:Tab 为 #anchor 链接,序列数据驱动(overview 恒有 → 自定义区块
# → Specifications/Features 按数据显隐);首个 active,其余 aria-selected=false
tabs = soup.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
assert len(tabs) == 2
assert 'Product Details' in tabs[0].get_text() and 'Specifications' in tabs[1].get_text()
assert tabs[0].get('data-panel') == 'overview' and tabs[1].get('data-panel') == 'specifications'
assert tabs[0].get('aria-selected') == 'true' and tabs[1].get('aria-selected') == 'false'
# 面板结构:overview 恒有(正文)+ specifications(specs 数据);首面板 active
panels = soup.select('.sf-tp-product-tabbed .sf-tp-panel')
assert len(panels) == 2
assert panels[0].get('id') == 'overview' and 'active' in panels[0].get('class', [])
assert panels[1].get('id') == 'specifications'
# 无 JS 平铺回退:tabs-enabled 只由脚本运行时添加(静态标记不带),
# 面板无内联 display 隐藏;panel-title 保留在标记中(由 CSS 类控制隐藏)
tabbed_el = soup.select_one('[data-sf-tp-tabbed]')
assert tabbed_el is not None and 'sf-tp-tabs-enabled' not in (tabbed_el.get('class') or [])
assert all(p.get('style') is None for p in panels)
assert len(tabs) == 5
assert [t.get('href') for t in tabs] == ['#overview', '#sec-packing-delivery',
'#sec-faq', '#specifications', '#features']
assert 'Product Details' in tabs[0].get_text()
assert 'Packing & Delivery' in tabs[1].get_text() and 'FAQ' in tabs[2].get_text()
assert 'Specifications' in tabs[3].get_text() and 'Features' in tabs[4].get_text()
assert tabs[0].name == 'a' and tabs[0].get('aria-selected') == 'true'
assert all(t.get('aria-selected') == 'false' for t in tabs[1:])
assert 'active' in (tabs[0].get('class') or [])
# 自定义区块:锚点 section + panel-title 标题(来自数据)+ rich-body 内容
packing = soup.select_one('#sec-packing-delivery.sf-tp-detail-section')
assert packing is not None
assert packing.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'Packing & Delivery'
assert 'Export grade packing on fumigated pallets.' in packing.select_one('.sf-tp-product-body').get_text()
faq = soup.select_one('#sec-faq')
assert faq is not None and faq.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'FAQ'
assert 'Lead time is 20 days' in faq.get_text()
# 正文区块:overview 只剩拆出后的正文(区块内容不重复渲染)
overview = soup.select_one('#overview')
assert 'HP-200 body copy' in overview.get_text()
assert 'Export grade packing' not in overview.get_text() and 'Lead time is 20 days' not in overview.get_text()
# 静态契约:区块全部平铺(无面板隐藏类/内联样式),panel-title 常显
assert 'sf-tp-tabs-enabled' not in html
sections_all = soup.select('.sf-tp-product-tabbed .sf-tp-detail-section')
assert len(sections_all) == 5 # overview + 2 自定义区块 + specifications + features
assert all(s.get('style') is None for s in sections_all)
assert soup.select_one('#overview .sf-tp-panel-title') is not None
assert soup.select_one('#specifications .sf-tp-panel-title') is not None
# 切换脚本在场(cms 尾部脚本移植:容器加 sf-tp-tabs-enabled + 面板 active 切换)
assert "classList.add('sf-tp-tabs-enabled')" in html
# 正文面板:rich-body 容器(WP 规格表就在正文里——不拆表,整体渲染)
assert soup.select_one('#overview .sf-tp-product-body') is not None
# 锚点脚本在场(Task 15 重做:平滑滚动 + scrollspy 高亮)
assert 'scrollIntoView' in html and "addEventListener('scroll'" in html
# 参数表:cms spec-table 每行两对键值(2 条 specs → 1 行 2 th + 2 td);
# fields 键值渲染(下划线转空格 + 首字母大写),品牌等管理字段不进表
# fields 键值渲染(下划线转空格 + 首字母大写),品牌/结构化 JSON 字段不进表
spec_table = soup.select_one('table.sf-tp-spec-table')
assert spec_table is not None
rows = spec_table.select('tbody tr')
@@ -249,21 +275,50 @@ def test_trade_pro_product_layout_structure(factory):
spec_text = spec_table.get_text()
assert 'Pressure' in spec_text and '210 bar' in spec_text and '500 km' in spec_text
assert 'Quanta' not in spec_text
assert 'Sections' not in spec_text and 'Features' not in spec_text and 'Gallery' not in spec_text
# Features 区块:fields.features 数据驱动(cms detail-feature-row 同构)
feats = soup.select('#features .sf-tp-detail-feature')
assert len(feats) == 2
assert feats[0].select_one('h3').get_text(strip=True) == 'High pressure'
assert '210 bar continuous rating.' in feats[0].get_text()
# 相关产品:复用 sf-tp-card-grid;自身不出现在相关列表
cards = soup.select('.sf-tp-card-grid .sf-tp-card')
assert cards and any('Companion Valve' in c.get_text() for c in cards)
assert all('Hydraulic Pump' not in c.get_text() for c in cards)
# 询盘板块存在(左联系信息 + 右 CTA/表单位)
assert soup.select_one('.sf-tp-product-quote') is not None
# CSS 契约(pro.css 12a 块):sticky 选项卡 / 无 JS 平铺回退 / 品牌色下划短线 /
# rich-body 表格覆盖内联宽高 / spec-table 移动端横滚
# ── 旧同步产物兜底:body 尾部 H2 段识别拆出为锚点区块 ──
r2 = factory.get(f'/preview/{sid}/{bid}/product/dn200/')
assert r2.status_code == 200, r2.text[:400]
soup2 = BeautifulSoup(r2.text, 'html.parser')
tabs2 = soup2.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
assert [t.get('href') for t in tabs2] == ['#overview', '#sec-packing-delivery', '#sec-our-exhibition']
assert 'Packing & Delivery' in tabs2[1].get_text() and 'Our Exhibition' in tabs2[2].get_text()
assert 'DN200 intro paragraph' in soup2.select_one('#overview').get_text()
assert 'Bundled in 6 m lengths.' not in soup2.select_one('#overview').get_text()
assert 'Bundled in 6 m lengths.' in soup2.select_one('#sec-packing-delivery').get_text()
assert 'Canton Fair' in soup2.select_one('#sec-our-exhibition').get_text()
assert soup2.select_one('#specifications') is None # 无 specs 数据不显 Tab
assert soup2.select_one('#features') is None # 无 features 数据不显 Tab
# ── 无自定义区块的产品:只有 Product Details + Specifications,无 sec-* Tab ──
r3 = factory.get(f'/preview/{sid}/{bid}/product/valve/')
assert r3.status_code == 200, r3.text[:400]
soup3 = BeautifulSoup(r3.text, 'html.parser')
tabs3 = soup3.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
assert [t.get_text() for t in tabs3] == ['Product Details', 'Specifications']
assert all(not (t.get('href') or '').startswith('#sec-') for t in tabs3)
# CSS 契约(pro.css 15 块):sticky 锚点选项卡 / scroll-margin 让位 /
# 品牌色下划短线 / detail-features 栅格 / rich-body 表格覆盖内联宽高 /
# spec-table 移动端横滚
pro_css = (ROOT / 'themes' / 'trade-pro' / 'pro.css').read_text(encoding='utf-8')
for needle in (
'.sf-tp-product-tabs{',
'sf-tp-tabs-enabled .sf-tp-panel{display:none}',
'sf-tp-tabs-enabled .sf-tp-panel.active{display:block}',
'sf-tp-tabs-enabled .sf-tp-panel-title{display:none}',
'.sf-tp-tab.active',
'text-decoration:none',
'scroll-margin-top',
'.sf-tp-detail-section > .wrap > h2{',
'.sf-tp-detail-features{display:grid',
'.sf-tp-detail-feature{display:grid',
'width:100% !important',
'height:auto !important',
'.sf-tp-spec-table{min-width:720px}',