upgrade: adopt V8.34.1 (multi-image banner carousel + spec-table source modes)

Plain overlay (33 items, same-source lineage, zero patch replay).
Theme suites 35/35; full run 453 passed + 10 known Windows env fails
(suspect-module rerun confirms identical failure set).
Live: container 8.34.1-dev.1; hesupipe home leftover industrial-preset
blocks cleared via studio draft+publish flow — home now renders pure
trade-pro template (sf-section/Chinese blocks = 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-26 18:03:07 +08:00
parent 391d22f1a8
commit df2a183dbf
38 changed files with 1652 additions and 521 deletions
-176
View File
@@ -798,182 +798,6 @@ 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/
+102
View File
@@ -0,0 +1,102 @@
"""V8.34.1: safe opt-in multi-banner and practical no-code product specs."""
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
from app.blueprints import new_block, validate_blocks, render_blocks, BLOCK_PROPERTY_SCHEMA
from test_factory_v2 import factory, site
ROOT=Path(__file__).resolve().parents[1]
def slides(*titles):
return [dict(title=t,subtitle='精选车型',description='图片说明',image='/media/car.webp',button='询价',href='/contact/') for t in titles]
def test_first_slide_priority_and_succeeding_lazy():
block=new_block('hero',{'slides':slides('A','B','C')})
html=render_blocks([block],{'name':'HESU'},{'route':'/','type':'home'},[])
soup=BeautifulSoup(html,'html.parser')
images=soup.select('.sf-hero-panel img')
assert len(images)==3
assert images[0]['loading']=='eager' and images[0]['fetchpriority']=='high'
assert [im['loading'] for im in images[1:]]==['lazy','lazy']
assert len(soup.select('[data-sf-slide-step]'))==2
assert soup.select_one('.sf-hero-count').text=='1 / 3'
assert [x.text for x in soup.select('.sf-hero-copy h1')]==['A']
assert [x.text for x in soup.select('.sf-hero-copy h2')]==['B','C']
def test_slides_are_opt_in_legacy_markup_unchanged():
old={'id':'legacy-banner-01','type':'hero','title':'Original','description':'Intro','image':'/media/x.jpg'}
html=render_blocks([old],{}, {'route':'/'},[])
assert 'sf-hero-slider' not in html and 'Original' in html
with_slide=new_block('hero',{'slides':slides('A')})
assert len(BeautifulSoup(render_blocks([with_slide],{}, {'route':'/about/'},[]),'html.parser').select('.sf-hero-panel'))==1
assert 'data-sf-slide-step' not in render_blocks([with_slide],{}, {'route':'/about/'},[])
@pytest.mark.parametrize('bad',[
[dict(title='<script>')]*7,
'fake',
[dict(title=55)],
[dict(title='one',evil='onerror')],
[dict(title='x'*2001)],
])
def test_slide_validator_rejects_invalid_payload(bad):
with pytest.raises(ValueError):validate_blocks([new_block('hero',{'slides':bad})])
def test_slide_text_xss_and_image_url_sanitized():
block=new_block('hero',{'slides':[dict(title='<script>evil()</script>',image='javascript:alert(1)',href='javascript:alert(1)',button='<b>hi</b>')]})
html=render_blocks([block],{}, {'route':'/'},[])
assert '<script>' not in html and 'javascript:' not in html
assert '&lt;script&gt;' in html and '&lt;b&gt;' in html
def test_product_spec_manual_merge_and_legacy():
page={'route':'/p/','type':'product','meta_json':'{"fields":{"brand":"BYD","range":"500km"}}'}
basic=new_block('attributes',{'title':'参数'})
initial=render_blocks([basic],{'schemas':{'product':[{'name':'brand','label':'品牌'}]}},page,[])
assert 'BYD' in initial
added=[{'label':'车长','value':'4780 mm'},{'label':'电池','value':'<script>alert(1)</script>'}]
manual=new_block('attributes',{'source_mode':'custom','items':added})
custom=render_blocks([manual],{},page,[])
assert '车长' in custom and 'BYD' not in custom
assert '<script>' not in custom
merged=render_blocks([new_block('attributes',{'source_mode':'merge','items':added})],{},page,[])
assert 'BYD' in merged and '车长' in merged
@pytest.mark.parametrize('mode,items',[
('evil',[]),('custom',[{'label':'A'}]),
('custom',[{'label':7,'value':'ok'}]),
('custom',[{'label':'A','value':'B','html':'evil'}]),
('custom',[{'label':'A','value':'B'}]*31),
])
def test_spec_validation_is_strict(mode,items):
with pytest.raises(ValueError):validate_blocks([new_block('attributes',{'source_mode':mode,'items':items})])
def test_editor_schema_and_no_code_actions():
assert BLOCK_PROPERTY_SCHEMA['hero']['properties']['slides']['type']=='array'
assert BLOCK_PROPERTY_SCHEMA['attributes']['properties']['items']['type']=='array'
script=(ROOT/'app/static/editor.js').read_text()
for key in ('多图 Banner(可选)','从图片素材库选择','从产品数据自动读取','产品数据 + 手动补充',"Studio.chooseMedia('slides'",'参数数值'):
assert key in script or key=='参数数值' and '数值' in script
runtime=(ROOT/'app/static/site-runtime.js').read_text()
assert 'data-sf-slide-step' in runtime and 'setInterval' not in runtime
def test_builded_slides_and_manual_specs(factory):
sid=site(factory)
pages=factory.get(f'/api/sites/{sid}/entries').json()
home=next(x for x in pages if x['route']=='/')
blocks=[new_block('hero',{'slides':slides('First','Second')}),new_block('attributes',{'title':'自定义参数','source_mode':'custom','items':[{'label':'长度','value':'1234 mm'}]})]
resp=factory.put(f'/api/sites/{sid}/pages/{home["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
assert resp.status_code==200,resp.text
b=factory.post(f'/api/sites/{sid}/builds',json={})
assert b.status_code==200,b.text
version=b.json()['version']
rendered=factory.get(f'/preview/{sid}/{version}/').text
assert 'data-sf-hero-slider' in rendered and 'Second' in rendered and '1234 mm' in rendered
assert 'data-sf-slide-step' in rendered
report=factory.get(f'/api/sites/{sid}/builds/{version}/report').json()
assert not report['errors']
+81
View File
@@ -0,0 +1,81 @@
"""V8.34 easy Banner/card presentation settings, validation and build parity."""
from pathlib import Path
from bs4 import BeautifulSoup
import pytest
from app import blueprints
from test_factory_v2 import factory, site
ROOT=Path(__file__).resolve().parents[1]
def test_banner_three_predefined_layouts_and_image_fit():
for variant in ('split','image-first','centered'):
block=blueprints.new_block('hero',{'title':'出口车型','image':'/assets/car.png',
'layout_variant':variant,'image_fit':'contain'})
markup=blueprints.render_blocks([block],{'name':'Demo'},{'route':'/','type':'home'},[])
soup=BeautifulSoup(markup,'html.parser')
assert soup.select_one(f'.sf-hero-layout-{variant}.sf-fit-contain')
assert soup.select_one('.sf-hero-img img').get('loading')=='eager'
assert soup.select_one('.sf-hero-img img').get('fetchpriority')=='high'
def test_collection_three_card_modes_and_product_photo_fit():
entries=[{'route':'/products/one/','title':'HDPE 管材','summary':'专业产品',
'type':'product','cover':'/media/hdpe.webp','meta_json':'{}'}]
for kind in ('standard','compact','showcase'):
block=blueprints.new_block('collection',{'card_style':kind,'image_fit':'contain'})
html=blueprints.render_blocks([block],{}, {'route':'/','type':'home'},entries)
assert f'sf-card-style-{kind} sf-fit-contain' in html
assert 'data-sf-filterable="false"' in html
assert 'hdpe.webp' in html
@pytest.mark.parametrize('typ,key,value',[
('hero','layout_variant','<script>alert(1)</script>'),
('hero','image_fit','calc(100vw)'),
('collection','card_style','custom'),
('collection','image_fit','url(javascript:1)'),
])
def test_invalid_presentation_options_blocked(typ,key,value):
with pytest.raises(ValueError,match='不支持的区块展示选项'):
blueprints.validate_blocks([blueprints.new_block(typ,{key:value})])
def test_legacy_blocks_preserve_default_appearance():
old_hero={'id':'hero-legacy-123','type':'hero','title':'旧首页','image':''}
old_list={'id':'cards-legacy-123','type':'collection','title':'旧列表','source':'product','limit':6,'columns':3}
markup=blueprints.render_blocks([old_hero,old_list],{}, {'route':'/','type':'home'},[])
assert 'sf-hero-layout-split' not in markup
assert 'sf-card-style-standard' not in markup
assert 'sf-fit-cover' not in markup
assert 'sf-section sf-collection' in markup
def test_studio_uses_plain_language_and_shared_presentation():
script=(ROOT/'app/static/editor.js').read_text()
stylesheet=(ROOT/'app/static/blocks.css').read_text()
for phrase in ('Banner 排版','文字左侧 · 图片右侧','图片左侧 · 文字右侧','图片优先 · 大图展示','完整显示(不裁切)'):
assert phrase in script
assert 'sf-hero-layout-image-first' in stylesheet
assert 'sf-card-style-showcase' in stylesheet
assert 'sf-fit-contain .sf-item-img img' in stylesheet
def test_real_preview_and_build_use_same_variants(factory):
sid=site(factory)
pages=factory.get(f'/api/sites/{sid}/entries').json()
home=next(x for x in pages if x['route']=='/')
blocks=[blueprints.new_block('hero',{'title':'产品供应','layout_variant':'image-first','image_fit':'contain'}),
blueprints.new_block('collection',{'title':'最新产品','card_style':'showcase','image_fit':'contain'})]
saved=factory.put(f'/api/sites/{sid}/pages/{home["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
assert saved.status_code==200,saved.text
built=factory.post(f'/api/sites/{sid}/builds',json={})
assert built.status_code==200,built.text
version=built.json()['version']
built_html=factory.get(f'/preview/{sid}/{version}/').text
assert 'sf-hero-layout-image-first sf-fit-contain' in built_html
assert 'sf-card-style-showcase sf-fit-contain' in built_html
css=factory.get(f'/preview/{sid}/{version}/assets/blocks.css')
assert css.status_code==200
assert 'sf-card-style-showcase' in css.text
assert not factory.get(f'/api/sites/{sid}/builds/{version}/report').json()['errors']