2026-10-22 17:14:44 +08:00
"""Theme discovery must be a single source of truth."""
import json
from pathlib import Path
2026-10-23 09:23:30 +08:00
from bs4 import BeautifulSoup
2026-10-22 17:14:44 +08:00
from app import core , theme_pro , theme_store
2026-10-22 18:19:30 +08:00
from test_factory_v2 import factory , site # noqa: F401 -- fixture + helper reused by trade-pro tests
2026-10-22 17:14:44 +08:00
ROOT = Path ( core . __file__ ) . resolve () . parent . parent
def test_discover_returns_builtin_themes_with_manifests ():
ids = core . discover_themes ()
for tid in ( 'automotive' , 'industrial' , 'corporate' , 'minimal' ):
assert tid in ids
for tid in ids :
manifest = json . loads (( ROOT / 'themes' / tid / 'manifest.json' ) . read_text ( encoding = 'utf-8' ))
assert manifest . get ( 'id' ) == tid
def test_three_whitelists_agree ():
2026-10-22 17:28:57 +08:00
# Tripwire: the three names must stay the same object. Fails only if someone
# reintroduces a hardcoded copy of the list anywhere.
2026-10-22 17:14:44 +08:00
assert set ( core . THEMES ) == set ( theme_pro . THEMES ) == set ( theme_store . BUILTIN )
assert isinstance ( core . THEMES , tuple )
2026-10-22 17:28:57 +08:00
def test_discover_skips_broken_theme_dirs ( tmp_path , monkeypatch ):
themes = tmp_path / 'themes'
good = themes / 'good-one' ; good . mkdir ( parents = True )
( good / 'manifest.json' ) . write_text ( json . dumps ({ 'id' : 'good-one' }), encoding = 'utf-8' )
broken = themes / 'broken' ; broken . mkdir ()
( broken / 'manifest.json' ) . write_text ( '{not json' , encoding = 'utf-8' )
mismatch = themes / 'mismatch' ; mismatch . mkdir ()
( mismatch / 'manifest.json' ) . write_text ( json . dumps ({ 'id' : 'other-name' }), encoding = 'utf-8' )
no_manifest = themes / 'no-manifest' ; no_manifest . mkdir ()
underscore = themes / '_shared' ; underscore . mkdir ()
monkeypatch . setattr ( core , 'ROOT' , tmp_path )
assert core . discover_themes () == ( 'good-one' ,)
def test_discover_fails_fast_when_themes_dir_missing ( tmp_path , monkeypatch ):
monkeypatch . setattr ( core , 'ROOT' , tmp_path )
import pytest
with pytest . raises ( RuntimeError , match = 'themes directory missing' ):
core . discover_themes ()
2026-10-22 17:33:05 +08:00
def test_env_for_theme_prefers_theme_dir_then_shared_root ():
from app import theme_pro
env = theme_pro . env_for ( 'industrial' )
paths = [ str ( p ) . replace ( ' \\ ' , '/' ) for p in env . loader . searchpath ]
assert paths [ 0 ] . endswith ( 'themes/industrial' )
assert paths [ 1 ] . endswith ( 'themes' )
# 共享宏库必须仍可解析(回落路径)
assert env . get_template ( '_professional_components.html' ) is not None
def test_env_for_is_cached_per_theme ():
from app import theme_pro
assert theme_pro . env_for ( 'industrial' ) is theme_pro . env_for ( 'industrial' )
def test_env_for_pro_template_loads_unprefixed ():
from app import theme_pro
assert theme_pro . env_for ( 'industrial' ) . get_template ( 'pro.html' ) is not None
2026-10-22 17:45:40 +08:00
def test_env_for_rejects_unknown_theme ():
import pytest
from app import theme_pro
with pytest . raises ( ValueError ):
theme_pro . env_for ( '../../etc' )
with pytest . raises ( ValueError ):
theme_pro . env_for ( 'no-such-theme' )
def test_env_for_theme_macro_shadows_shared_root ( tmp_path , monkeypatch ):
# The core mechanism: a theme-local _professional_components.html wins over
# the shared one at themes/ root.
from app import theme_pro
themes = tmp_path / 'themes'
for sub , marker in (( 'industrial' , 'theme-local' ), ( '_root_macros' , None )):
d = themes / sub
d . mkdir ( parents = True )
( themes / '_professional_components.html' ) . write_text ( 'SHARED' , encoding = 'utf-8' )
( themes / 'industrial' / '_professional_components.html' ) . write_text ( 'THEME-LOCAL' , encoding = 'utf-8' )
monkeypatch . setattr ( theme_pro , 'ROOT' , tmp_path )
monkeypatch . setattr ( theme_pro , '_ENVS' , {})
tpl = theme_pro . env_for ( 'industrial' ) . get_template ( '_professional_components.html' )
assert tpl . render () . strip () == 'THEME-LOCAL'
def test_env_for_degraded_branch_not_cached_and_single_path ( tmp_path , monkeypatch ):
from app import theme_pro
themes = tmp_path / 'themes'
themes . mkdir ( parents = True )
monkeypatch . setattr ( theme_pro , 'ROOT' , tmp_path )
monkeypatch . setattr ( theme_pro , '_ENVS' , {})
core . THEMES = ( 'industrial' ,) # whitelist a theme whose dir does not exist
try :
env = theme_pro . env_for ( 'industrial' )
assert [ str ( p ) for p in env . loader . searchpath ] == [ str ( themes )]
assert 'industrial' not in theme_pro . _ENVS
finally :
core . THEMES = core . discover_themes ()
2026-10-22 17:49:12 +08:00
2026-10-22 18:19:30 +08:00
def test_trade_pro_registered_and_compatible ():
assert 'trade-pro' in core . THEMES
result = theme_store . compatibility ( 'trade-pro' )
assert result [ 'valid' ], result [ 'errors' ]
assert not result [ 'errors' ]
def test_trade_pro_builds_real_site ( factory ):
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
selected = factory . put ( f '/api/factory/sites/ { sid } /theme' , json = { 'theme' : 'trade-pro' , 'version' : '1.0.0' })
assert selected . status_code == 200 , selected . text
2026-10-23 12:31:03 +08:00
# V8.15+ multilingual rewrites <html lang> per site locale — declare English
loc = factory . put ( f '/api/i18n/sites/ { sid } /locale' , json = { 'locale' : 'en' })
assert loc . status_code == 200 , loc . text
2026-10-22 18:19:30 +08:00
result = factory . post ( f '/api/sites/ { sid } /builds' , json = {})
assert result . status_code == 200 , result . text
bid = result . json ()[ 'version' ]
home = factory . get ( f '/preview/ { sid } / { bid } /' )
assert home . status_code == 200 , home . text [: 400 ]
assert 'lang="en"' in home . text
2026-10-22 18:40:49 +08:00
assert '>Products<' in home . text # English fallback nav actually rendered
2026-10-22 18:19:30 +08:00
assert '>首页<' not in home . text
assert '<meta name="theme-color" content="#0b3d91"' in home . text
2026-10-23 08:07:34 +08:00
def test_trade_pro_theme_css_ports_token_system ():
from pathlib import Path
css = ( Path ( __file__ ) . resolve () . parent . parent / 'themes' / 'trade-pro' / 'theme.css' ) . read_text ( encoding = 'utf-8' )
# 令牌覆盖:cms tokens.css 的核心令牌语义都应有对应 --sf-tp-* 变量
for token in ( 'primary' , 'accent' , 'bg' , 'text' , 'muted' , 'radius' , 'font' ):
assert f '--sf-tp- { token } ' in css , token
# 移植覆盖:cms 语义令牌以 --sf-tp- 前缀保留语义名(tokens.css 各组取样)
for token in ( 'color-brand-primary' , 'color-brand-secondary' , 'color-neutral-900' , 'color-text-primary' ,
'color-surface-page' , 'color-border-default' , 'font-family-sans' , 'font-size-display' ,
'font-weight-bold' , 'line-height-normal' , 'space-4' , 'space-section-y' , 'container-page-max' ,
'breakpoint-tablet' , 'radius-full' , 'shadow-md' , 'button-height-md' , 'input-border-radius' ,
'card-padding-md' , 'duration-fast' , 'easing-standard' , 'z-modal' , 'focus-outline-color' , 'icon-size-md' ):
assert f '--sf-tp- { token } ' in css , token
# 基础样式:base.css 元素基线、按钮体系与响应式断点已移植
for fragment in ( 'box-sizing' , '.sf-tp-btn--lg' , '.sf-tp-btn--ghost' , '@media (max-width: 620px)' ,
'prefers-reduced-motion' ):
assert fragment in css , fragment
# 样式隔离红线:不得出现平台按钮选择器
import re
for sel in ( '.btn{' , '.primary{' , '.request{' , '.button{' , '.nav-btn{' ):
assert sel not in css , sel
def test_trade_pro_build_contains_tokens ( factory ):
# 构建产物 assets/theme.css 含 --sf-tp- 变量(复用 test_trade_pro_builds_real_site 的构建流程)
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
selected = factory . put ( f '/api/factory/sites/ { sid } /theme' , json = { 'theme' : 'trade-pro' , 'version' : '1.0.0' })
assert selected . status_code == 200 , selected . text
result = factory . post ( f '/api/sites/ { sid } /builds' , json = {})
assert result . status_code == 200 , result . text
bid = result . json ()[ 'version' ]
home = factory . get ( f '/preview/ { sid } / { bid } /' )
assert home . status_code == 200 , home . text [: 400 ]
assert 'assets/theme.css' in home . text # 首页 HTML 确实链接主题样式表
css = factory . get ( f '/preview/ { sid } / { bid } /assets/theme.css' )
assert css . status_code == 200 , css . text [: 200 ]
assert '--sf-tp-primary' in css . text # Phase 2 短令牌别名随构建分发
assert '--sf-tp-color-brand-primary' in css . text # cms 令牌全集随构建分发
2026-10-23 09:23:30 +08:00
def test_trade_pro_home_layout_structure ( factory ):
# Task 10a: home.html 移植 cms 首页设计后的结构契约(sf-tp-* 组件类,
# 数据区块由 preview 的 3 个演示产品驱动;无 pro-industrial-hero 残留)。
r = factory . get ( '/api/factory/themes/trade-pro/preview' , params = { 'page_type' : 'home' })
assert r . status_code == 200 , r . text [: 400 ]
soup = BeautifulSoup ( r . text , 'html.parser' )
assert soup . select_one ( 'section.sf-tp-hero' ) is not None
assert 'pro-industrial-hero' not in r . text
assert soup . select_one ( '.sf-tp-hero .sf-tp-hero-copy[data-reveal] h1' ) is not None
assert soup . select_one ( '.sf-tp-hero .sf-tp-hero-overlay' ) is not None
assert len ( soup . select ( '.sf-tp-trustbar .sf-tp-trust-item' )) == 4
assert soup . select_one ( '.sf-tp-trust-item .icon-circle svg' ) is not None
assert soup . select_one ( '.sf-tp-two-col .sf-tp-copy .sf-tp-btn[href="/about/"]' ) is not None
assert soup . select_one ( '.sf-tp-section-head' ) is not None
cards = soup . select ( '.sf-tp-card-grid .sf-tp-card' )
assert len ( cards ) == 3 # preview 提供 3 个演示产品
assert cards [ 0 ] . select_one ( '.sf-tp-card-body .sf-tp-more[href]' ) is not None
assert soup . select_one ( '.sf-tp-feature-grid .sf-tp-feature .icon-circle svg' ) is not None
assert soup . select_one ( '.sf-tp-cta .sf-tp-btn--ghost[href="/contact/"]' ) is not None
# reveal 渐进增强(pro.html 尾部内联 JS):html.js 开关 + IntersectionObserver
assert "classList.add('js')" in r . text and 'IntersectionObserver' in r . text
assert len ( soup . find_all ( 'h1' )) == 1
2026-10-23 10:17:31 +08:00
def test_trade_pro_product_layout_structure ( factory ):
2026-10-29 13:49:08 +08:00
# Task 15: product.html 锚点选项卡 + 每产品自定义区块(重做 12a 面板切换)。
# Tab 序列对齐 cms detailTabs: Product Details(正文)→ 自定义区块
# ( wppop_sections 迁移,标题来自每产品数据不固定)→ Specifications →
# Features(后两者按数据显隐)。Tab 为锚点跳转非面板切换:区块全部平铺
# 渲染、panel-title 常显(无 JS 原生锚点跳转可读、静态可抓取);JS 渐进
# 增强平滑滚动 + scrollspy 高亮。区块数据双来源:fields.sections 结构化
# 优先;旧同步产物 body 尾部 H2 段按标题识别拆出(正文不重复渲染)。
2026-10-23 10:17:31 +08:00
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.' ,
2026-10-29 13:49:08 +08:00
'body' : '<p>HP-200 body copy with export-grade tolerances.</p>' ,
2026-10-23 10:17:31 +08:00
'meta' : { 'fields' : { 'brand' : 'Quanta' , 'range' : '500 km' , 'pressure' : '210 bar' ,
2026-10-29 13:49:08 +08:00
'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.' }])}}})
2026-10-23 10:17:31 +08:00
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/' ,
2026-10-29 13:49:08 +08:00
'summary' : 'Matching control valve.' ,
'meta' : { 'fields' : { 'material' : 'Stainless steel' }}})
2026-10-23 10:17:31 +08:00
assert other . status_code in ( 200 , 201 ), other . text
2026-10-29 13:49:08 +08:00
# 旧同步产物形态:区块以 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
2026-10-23 10:17:31 +08:00
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/' )
assert r . status_code == 200 , r . text [: 400 ]
html = r . text
soup = BeautifulSoup ( html , 'html.parser' )
# 画廊:主图为封面;多图时缩略图带 data-gallery-image( pro.js 切换/灯箱钩子)
media = soup . select_one ( '.sf-tp-product-grid .sf-tp-product-media img' )
assert media is not None and 'hp-200.jpg' in media . get ( 'src' , '' )
thumbs = soup . select ( '.sf-tp-product-thumbs [data-gallery-image]' )
assert len ( thumbs ) == 2
2026-10-28 11:42:51 +08:00
# 标题区:产品名用 cms h2 量级(2xl 非巨型;平台全站单 h1 契约保留 h1)+ 询盘 CTA
2026-10-28 11:30:19 +08:00
# ( /preview/ 响应会把根相对 href 重写为 /preview/{sid}/{bid}/contact/,故用后缀匹配)
2026-10-28 11:42:51 +08:00
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 一致)
2026-10-23 10:17:31 +08:00
assert soup . select_one ( '.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]' )
2026-10-29 13:49:08 +08:00
# 锚点选项卡:Tab 为 #anchor 链接,序列数据驱动(overview 恒有 → 自定义区块
# → Specifications/Features 按数据显隐);首个 active,其余 aria-selected=false
2026-10-28 11:30:19 +08:00
tabs = soup . select ( '.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab' )
2026-10-29 13:49:08 +08:00
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 )
2026-10-28 11:30:19 +08:00
assert soup . select_one ( '#overview .sf-tp-panel-title' ) is not None
2026-10-29 13:49:08 +08:00
# 锚点脚本在场(Task 15 重做:平滑滚动 + scrollspy 高亮)
assert 'scrollIntoView' in html and "addEventListener('scroll'" in html
2026-10-28 11:30:19 +08:00
# 参数表:cms spec-table 每行两对键值(2 条 specs → 1 行 2 th + 2 td);
2026-10-29 13:49:08 +08:00
# fields 键值渲染(下划线转空格 + 首字母大写),品牌/结构化 JSON 字段不进表
2026-10-23 10:17:31 +08:00
spec_table = soup . select_one ( 'table.sf-tp-spec-table' )
assert spec_table is not None
2026-10-28 11:30:19 +08:00
rows = spec_table . select ( 'tbody tr' )
assert len ( rows ) == 1
assert len ( rows [ 0 ] . select ( 'th' )) == 2 and len ( rows [ 0 ] . select ( 'td' )) == 2
2026-10-23 10:17:31 +08:00
spec_text = spec_table . get_text ()
assert 'Pressure' in spec_text and '210 bar' in spec_text and '500 km' in spec_text
2026-10-28 11:30:19 +08:00
assert 'Quanta' not in spec_text
2026-10-29 13:49:08 +08:00
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 ()
2026-10-23 10:17:31 +08:00
# 相关产品:复用 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
2026-10-29 13:49:08 +08:00
# ── 旧同步产物兜底: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 移动端横滚
2026-10-28 11:30:19 +08:00
pro_css = ( ROOT / 'themes' / 'trade-pro' / 'pro.css' ) . read_text ( encoding = 'utf-8' )
for needle in (
'.sf-tp-product-tabs{' ,
2026-10-29 13:49:08 +08:00
'.sf-tp-tab.active' ,
'text-decoration:none' ,
'scroll-margin-top' ,
2026-10-28 11:30:19 +08:00
'.sf-tp-detail-section > .wrap > h2{' ,
2026-10-29 13:49:08 +08:00
'.sf-tp-detail-features{display:grid' ,
'.sf-tp-detail-feature{display:grid' ,
2026-10-28 11:30:19 +08:00
'width:100% !important' ,
'height:auto !important' ,
'.sf-tp-spec-table{min-width:720px}' ,
):
assert needle in pro_css , needle
2026-10-23 10:17:31 +08:00
# 旧骨架清理:无 .pro-product-layout/.pro-specs 残留类依赖
assert 'pro-product-layout' not in html and 'pro-specs' not in html
2026-10-23 14:06:15 +08:00
def test_trade_pro_listing_layout_structure ( factory ):
# Task 10c: listing.html( category.html 同构复用)移植 cms 列表设计后的结构契约。
# 设计源 D:/project/cms/runtimes/astro-site-runtime/src/pages/[locale]/
# products/[...page].astro( 260px 分类侧栏 + 3 列卡片栅格 + 窗口式分页)
# news/[...page].astro( 310px 侧栏 + 横向新闻行列表 + 侧栏盒组)
# 平台契约保留:[data-pro-filter-search] 筛选、.pro-filter-panel 必须是
# .pro-filter-scope 的紧邻前兄弟(pro.js previousElementSibling 定位)、
# 卡片/行携带 .pro-card + data-brand/data-category( pro.js 过滤数据源)。
# 分页器:cms 窗口式页码(首尾 + 当前±2),上一页/下一页为 SVG 箭头。
import io
import re
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
2026-10-28 13:35:46 +08:00
# 105 个产品:/categories/pumps/ 走 render_paginated( LIST_PAGE_SIZE=20,
# 六页真实静态分页路由),比 101 次 /entries POST 便宜得多
2026-10-23 14:06:15 +08:00
products = [{ 'type' : 'product' , 'title' : f 'Bulk Pump { i : 03d } ' ,
'summary' : f 'Bulk pump model { i : 03d } for export.' ,
'meta' : { 'fields' : { 'brand' : 'Quanta' , 'category' : 'Pumps' }}}
for i in range ( 105 )]
posts = [{ 'type' : 'post' , 'title' : f 'Worksite Report { i } ' ,
'summary' : f 'Installation report from worksite { i } .' } for i in range ( 3 )]
payload = json . dumps ( products + posts ) . encode ( 'utf-8' )
imported = factory . post ( f '/api/sites/ { sid } /import' ,
files = { 'file' : ( 'listing-batch.json' , io . BytesIO ( payload ), 'application/json' )})
assert imported . status_code == 200 , imported . text [: 300 ]
assert imported . json ()[ 'imported' ] == 108 , imported . json ()
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' ]
# ── 产品分支(/products/):hero + 分类侧栏 + 3 列卡片栅格 + 筛选契约 ──
html = factory . get ( f '/preview/ { sid } / { bid } /products/' ) . text
soup = BeautifulSoup ( html , 'html.parser' )
assert soup . select_one ( 'section.sf-tp-page-hero .sf-tp-page-hero-copy[data-reveal] h1' )
assert len ( soup . find_all ( 'h1' )) == 1
side = soup . select_one ( '.sf-tp-side-panel' )
assert side is not None and side . find ( 'h3' )
assert side . select_one ( 'a.sf-tp-side-link[href$="/products/"]' )
assert side . select_one ( f 'a.sf-tp-side-link[href$="/categories/pumps/"]' )
# 筛选契约:面板在 scope 紧邻前兄弟位(pro.js 依赖),卡片带过滤数据
assert soup . select_one ( '.pro-filter-panel [data-pro-filter-search]' )
scope = soup . select_one ( '.pro-filter-scope' )
assert scope is not None and scope . find_previous_sibling ( class_ = 'pro-filter-panel' ) is not None
cards = soup . select ( '.sf-tp-card-grid--listing a.sf-tp-card.pro-card' )
assert len ( cards ) == 105
assert cards [ 0 ] . get ( 'data-brand' ) == 'Quanta' and cards [ 0 ] . get ( 'data-category' ) == 'Pumps'
assert cards [ 0 ] . select_one ( '.sf-tp-card-media' ) and cards [ 0 ] . select_one ( '.sf-tp-more svg' )
# ── 分页(派生分类页走构建期静态分页):窗口页码 + SVG 箭头 ──
cat1 = factory . get ( f '/preview/ { sid } / { bid } /categories/pumps/' )
assert cat1 . status_code == 200 , cat1 . text [: 200 ]
s1 = BeautifulSoup ( cat1 . text , 'html.parser' )
2026-10-23 14:26:34 +08:00
assert len ( s1 . find_all ( 'h1' )) == 1
2026-10-23 14:06:15 +08:00
nav1 = s1 . select_one ( 'nav.sf-tp-pagination' )
assert nav1 is not None
2026-10-28 13:35:46 +08:00
assert len ( s1 . select ( '.sf-tp-card-grid--listing .sf-tp-card' )) == 20
2026-10-23 14:06:15 +08:00
active = nav1 . select_one ( '.sf-tp-page-num.is-active[aria-current="page"]' )
assert active is not None and active . get_text ( strip = True ) == '1'
nxt = nav1 . select_one ( 'a[rel="next"]' )
assert nxt is not None and nxt . get ( 'href' , '' ) . endswith ( '/categories/pumps/page/2/' )
assert nxt . select_one ( 'svg' ) is not None # 下一页箭头为 SVG
assert nav1 . select_one ( 'a[rel="prev"]' ) is None # 首页无上一页链接
assert nav1 . select_one ( '.sf-tp-page-arrow.is-disabled' ) is not None # 禁用态占位
cat2 = factory . get ( f '/preview/ { sid } / { bid } /categories/pumps/page/2/' )
assert cat2 . status_code == 200 , cat2 . text [: 200 ]
s2 = BeautifulSoup ( cat2 . text , 'html.parser' )
2026-10-23 14:26:34 +08:00
assert len ( s2 . find_all ( 'h1' )) == 1
2026-10-23 14:06:15 +08:00
nav2 = s2 . select_one ( 'nav.sf-tp-pagination' )
2026-10-28 13:35:46 +08:00
assert len ( s2 . select ( '.sf-tp-card-grid--listing .sf-tp-card' )) == 20
2026-10-23 14:06:15 +08:00
prev = nav2 . select_one ( 'a[rel="prev"]' )
assert prev is not None and prev . get ( 'href' , '' ) . endswith ( '/categories/pumps/' )
assert prev . select_one ( 'svg' ) is not None
2026-10-28 13:38:21 +08:00
# 20/页时第 2 页不是末页(105 条 → 6 页),存在下一页链接
nxt2 = nav2 . select_one ( 'a[rel="next"]' )
assert nxt2 is not None and nxt2 . get ( 'href' , '' ) . endswith ( '/categories/pumps/page/3/' )
2026-10-23 14:06:15 +08:00
assert nav2 . select_one ( '.sf-tp-page-num.is-active' ) . get_text ( strip = True ) == '2'
# 页码链接回根路由(第 1 页 = 栏目根,与 cms Pagination 一致)
assert nav2 . select_one ( 'a.sf-tp-page-num[href$="/categories/pumps/"]' )
for nav in ( nav1 , nav2 ):
assert not re . search ( r '[‹›→←]' , nav . get_text ()) # 无裸箭头字符
# ── 文章分支(/news/):新闻行列表 + 310px 侧栏盒组,筛选契约同样保留 ──
news = factory . get ( f '/preview/ { sid } / { bid } /news/' ) . text
nsoup = BeautifulSoup ( news , 'html.parser' )
assert len ( nsoup . find_all ( 'h1' )) == 1
assert nsoup . select_one ( '.pro-filter-panel [data-pro-filter-search]' )
nscope = nsoup . select_one ( '.pro-filter-scope' )
assert nscope is not None and nscope . find_previous_sibling ( class_ = 'pro-filter-panel' ) is not None
rows = nsoup . select ( '.sf-tp-news-row.pro-card[data-reveal]' )
assert len ( rows ) == 3
row = rows [ 0 ]
assert row . select_one ( '.sf-tp-news-row-media img, .sf-tp-news-row-media .pro-art' )
assert row . select_one ( 'h3 a[href$="/post/worksite-report-0/"]' )
assert row . select_one ( 'a.sf-tp-more svg' ) # Read more → SVG 箭头
aside = nsoup . select_one ( '.sf-tp-news-aside' )
assert aside is not None
assert len ( aside . select ( '.sf-tp-aside-box' )) == 3 # Latest / Popular / Categories
assert aside . select_one ( '.sf-tp-aside-box a[href$="/post/worksite-report-0/"]' )
assert not nsoup . select_one ( '.sf-tp-side-panel' ) # 文章分支不带产品分类侧栏
assert len ( nsoup . select ( 'nav.sf-tp-pagination' )) == 0 # 3 条不分页,整段隐藏
# ── 空列表(/cases/ 无案例):整段隐藏换友好空态,不渲染筛选 ──
cases = factory . get ( f '/preview/ { sid } / { bid } /cases/' ) . text
assert 'sf-tp-listing-empty' in cases
2026-10-23 14:26:34 +08:00
assert 'data-pro-filter-search' not in cases
assert 'sf-tp-pagination' not in cases
2026-10-23 14:06:15 +08:00
2026-10-23 15:04:07 +08:00
def test_trade_pro_article_layout_structure ( factory ):
# Task 10d: article.html 移植 cms 文章详情设计(news/[slug].astro,像素基准
# dist/hesupipe/en/news/<slug>/)后的结构契约。构建流(/preview/ HTTP) +
# 带 cover/publish_date/富文本正文的文章条目 + 4 条其他文章驱动断言:
# hero( .pro-article-hero 平台契约保留 + 封面底图 + 面包屑)、meta 行
# (分类胶囊 · 发布日期 · 作者)、富文本正文容器、作者卡、下一篇导航、
# More News 相关盒(不含自身、不含下一篇的去重列表)+ 侧栏 CTA + 返回;
# 交互箭头一律 SVG,无裸箭头字符。
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' : 'post' , 'title' : 'HDPE Pipe Export Standards Update' , 'route' : '/news/hdpe-standards/' ,
'cover' : 'https://imgs.example/hdpe-cover.jpg' , 'summary' : 'Updated inspection rules for HDPE pipe exports.' ,
'body' : '<p>Inspection requirements for <strong>HDPE</strong> pipe exports.</p><h2>Packaging</h2><p>Bundles are wrapped and strapped.</p>' ,
'meta' : { 'fields' : { 'category' : 'Company News' , 'publish_date' : '2026-10-18' , 'author' : 'Trade Desk' }}})
assert made . status_code in ( 200 , 201 ), made . text
for i in range ( 4 ):
r = factory . post ( f '/api/sites/ { sid } /entries' , json = {
'type' : 'post' , 'title' : f 'Worksite Report { i } ' , 'route' : f '/news/worksite- { i } /' ,
'summary' : f 'Installation report from worksite { i } .' })
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' ]
r = factory . get ( f '/preview/ { sid } / { bid } /news/hdpe-standards/' )
assert r . status_code == 200 , r . text [: 400 ]
html = r . text
soup = BeautifulSoup ( html , 'html.parser' )
# hero: .pro-article-hero 契约保留(test_theme_pro_v3 对所有主题断言)+
# 封面作 hero 底图 + 面包屑(Home › News › 标题)+ 唯一 h1 + reveal
hero = soup . select_one ( 'section.sf-tp-article-hero.pro-article-hero' )
assert hero is not None
bg = hero . select_one ( '.sf-tp-page-hero-bg' )
assert bg is not None and 'hdpe-cover.jpg' in bg . get ( 'src' , '' )
assert hero . select_one ( '.sf-tp-breadcrumb a[href$="/news/"]' )
assert hero . select_one ( '.sf-tp-page-hero-copy[data-reveal] h1' )
assert len ( soup . find_all ( 'h1' )) == 1
# meta 行:分类胶囊 + 发布日期(time)+ 作者字段
meta = soup . select_one ( '.sf-tp-article-meta' )
assert meta is not None and 'Company News' in meta . get_text ()
assert meta . find ( 'time' ) is not None and '2026-10-18' in meta . find ( 'time' ) . get_text ()
assert 'Trade Desk' in meta . get_text ()
# 正文:body_copy 富文本容器(加粗与小节标题随文渲染)
body = soup . select_one ( '.sf-tp-article-body .pro-rich' )
assert body is not None and 'HDPE' in body . get_text () and 'Packaging' in body . get_text ()
# 分隔线 + 作者卡(首字母头像)
assert soup . select_one ( '.sf-tp-article-divider' ) is not None
assert soup . select_one ( '.sf-tp-article-author .sf-tp-article-avatar' ) is not None
# 下一篇导航:排除自身后的首条其他文章
nxt = soup . select_one ( 'a.sf-tp-next-story' )
assert nxt is not None and 'Worksite Report' in nxt . get_text ()
# 相关阅读:More News 盒 3 条(其余文章),不含自身、不与下一篇重复
items = soup . select ( '.sf-tp-article-side-box a.sf-tp-article-side-item' )
assert len ( items ) == 3
texts = [ i . get_text () for i in items ]
assert all ( 'HDPE Pipe Export Standards' not in t for t in texts )
assert nxt . get ( 'href' ) not in [ i . get ( 'href' ) for i in items ]
assert items [ 0 ] . select_one ( '.sf-tp-card-media img, .sf-tp-card-media .pro-art' )
# 侧栏 CTA 指向联系页;返回链接回 /news/
assert soup . select_one ( '.sf-tp-article-side-cta a.sf-tp-btn[href$="/contact/"]' )
assert soup . select_one ( 'a.sf-tp-article-back[href$="/news/"]' )
# 交互箭头一律 SVG(Task 9 契约),链接文本无裸箭头字符
assert not re . search ( r '[←→↗↘⌕▾]' , '' . join ( a . get_text () for a in soup . find_all ( 'a' )))
2026-10-23 15:58:30 +08:00
def test_trade_pro_about_layout_structure ( factory ):
# Task 10e: about.html 移植 cms 关于页设计(about.astro,像素基准
# dist/hesupipe/en/about/)后的结构契约。
# 区块序列:PageHero(面包屑 + 封面底图 + 页内锚点按钮)→ 公司介绍两栏
# (文案 + 实景图 + 悬浮数据条)→ 富文本正文(仅当 body 存在)→
# 优势特性网格 → 深色使命带(实景底图 + 玻璃卡)→ 厂区图集卡(仅当
# gallery 存在)→ 资质特性网格。平台事实:about 数据 = 普通页面条目
# ( title/summary/body/cover + fields.gallery),cms 词典中的 stats/
# 厂区文案无对应字段——stats 复用 10a 首页的派生计数(产品/分类/品牌/
# 案例),厂区卡片由 page.cover+fields.gallery 驱动,均空则整段隐藏。
# 双构建断言:数据齐备的 /about/(bootstrap 必备栏目路由)+ 空数据的
# 自定义路由 about 页(meta.page_template 显式指定,不绑定特定路由)。
import re
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
# 数据齐备的 about 条目:PATCH bootstrap 建立的 /about/(保留原路由)
entries = factory . get ( f '/api/sites/ { sid } /entries' ) . json ()
about_id = next ( e [ 'id' ] for e in entries if e [ 'route' ] == '/about/' )
cover = 'https://imgs.example/factory-floor.jpg'
shot1 = 'https://imgs.example/workshop-line.jpg'
shot2 = 'https://imgs.example/finished-goods.jpg'
patched = factory . patch ( f '/api/sites/ { sid } /entries/ { about_id } ' , json = {
'title' : 'About Us' , 'summary' : 'Manufacturer and export partner since day one.' ,
'body' : '<p>Quality control runs from raw material to loaded container.</p><h2>Our standards</h2><p>Every batch is inspected before dispatch.</p>' ,
'cover' : cover ,
'meta' : { 'fields' : { 'gallery' : f ' { shot1 } ; { shot2 } ' }}})
assert patched . status_code == 200 , patched . text
# 3 个产品(1 品牌 1 分类)+ 1 篇文章:驱动派生 stats 与 /news/ 链接
batch = [{ 'type' : 'product' , 'title' : f 'Valve Series { i } ' , 'summary' : f 'Control valve { i } .' ,
'meta' : { 'fields' : { 'brand' : 'Quanta' , 'category' : 'Valves' }}} for i in range ( 3 )]
batch . append ({ 'type' : 'post' , 'title' : 'Plant Expansion Update' , 'summary' : 'New extrusion lines.' })
import io
payload = json . dumps ( batch ) . encode ( 'utf-8' )
imported = factory . post ( f '/api/sites/ { sid } /import' ,
files = { 'file' : ( 'about-batch.json' , io . BytesIO ( payload ), 'application/json' )})
assert imported . status_code == 200 , imported . text [: 300 ]
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' ]
# ── 数据齐备 /about/: hero + 介绍两栏 + 正文 + 四个设计区块 ──
html = factory . get ( f '/preview/ { sid } / { bid } /about/' ) . text
soup = BeautifulSoup ( html , 'html.parser' )
assert len ( soup . find_all ( 'h1' )) == 1
hero = soup . select_one ( 'section.sf-tp-page-hero.sf-tp-about-hero' )
assert hero is not None
bg = hero . select_one ( '.sf-tp-page-hero-bg' )
assert bg is not None and 'factory-floor.jpg' in bg . get ( 'src' , '' )
# 面包屑:Home + 当前页标题(非链接)
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 ) == 'About Us'
# hero 锚点按钮(cms PageHero button → #main 的页内对应物)
assert hero . select_one ( '.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-about-intro"]' )
assert 'Manufacturer and export partner' in hero . select_one ( '.sf-tp-page-hero-lead' ) . get_text ()
# 介绍两栏:文案列(摘要 + 有文章时的 /news/ 链接)+ 实景图 + 派生数据条
intro = soup . select_one ( 'section.sf-tp-about-intro .sf-tp-two-col' )
assert intro is not None
assert intro . select_one ( '.sf-tp-copy h2' )
assert 'Manufacturer and export partner' in intro . select_one ( '.sf-tp-copy p' ) . get_text ()
assert intro . select_one ( '.sf-tp-copy a.sf-tp-btn[href$="/news/"]' )
photo = intro . select_one ( '.sf-tp-photo img, .sf-tp-photo .sf-tp-photo-plain' )
assert photo is not None
stats = intro . select ( '.sf-tp-stats .sf-tp-stat' )
assert len ( stats ) >= 2 and 'Products' in intro . select_one ( '.sf-tp-stats' ) . get_text ()
# 富文本正文:body 存在才渲染(cms isRichAbout 分支的平台化)
body = soup . select_one ( '.sf-tp-about-body .pro-rich' )
assert body is not None and 'Quality control' in body . get_text () and 'Our standards' in body . get_text ()
# 优势 / 使命 / 资质:静态设计内容(cms 词典区块),特性卡带 SVG 图标
assert len ( soup . select ( '.sf-tp-about-advantages .sf-tp-feature' )) == 4
assert all ( f . select_one ( '.icon-circle svg' ) for f in soup . select ( '.sf-tp-about-advantages .sf-tp-feature' ))
mission = soup . select_one ( 'section.section--dark.sf-tp-about-mission' )
assert mission is not None
assert len ( mission . select ( '.sf-tp-feature' )) == 4
assert mission . select_one ( '.sf-tp-intro' )
assert len ( soup . select ( '.sf-tp-about-certs .sf-tp-feature' )) == 4
# 厂区图集卡:cover + fields.gallery 去重共 3 图
facilities = soup . select_one ( '.sf-tp-about-facilities' )
assert facilities is not None
cards = facilities . select ( '.sf-tp-about-facility' )
assert len ( cards ) == 3
srcs = ' ' . join ( i . get ( 'src' , '' ) for i in facilities . select ( 'img' ))
assert 'workshop-line.jpg' in srcs and 'finished-goods.jpg' in srcs
# 交互箭头一律 SVG(Task 9 契约)
assert not re . search ( r '[←→↗↘⌕▾]' , '' . join ( a . get_text () for a in soup . find_all ( 'a' )))
# ── 空数据 about(自定义路由 + meta.page_template 显式指定)──
# 无 cover/body/gallery/产品:hero 无底图、正文与图集整段隐藏、stats
# 整段隐藏、/news/ 链接不渲染;静态设计区块保留(完整页面而非两栏摘要)。
sid2 = site ( factory )
made = factory . post ( f '/api/sites/ { sid2 } /entries' , json = {
'type' : 'page' , 'title' : 'Our Story' , 'route' : '/our-story/' ,
'meta' : { 'page_template' : 'about' }})
assert made . status_code in ( 200 , 201 ), made . 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' ]
bare = factory . get ( f '/preview/ { sid2 } / { bid2 } /our-story/' )
assert bare . status_code == 200 , bare . text [: 300 ]
bsoup = BeautifulSoup ( bare . text , 'html.parser' )
assert len ( bsoup . find_all ( 'h1' )) == 1
assert bsoup . select_one ( 'section.sf-tp-about-hero' ) is not None
assert bsoup . select_one ( '.sf-tp-page-hero-bg' ) is None
assert bsoup . select_one ( '.sf-tp-about-body' ) is None
assert bsoup . select_one ( '.sf-tp-about-facilities' ) is None
assert bsoup . select_one ( '.sf-tp-stats' ) is None
assert bsoup . select_one ( '.sf-tp-about-intro .sf-tp-copy a.sf-tp-btn[href$="/news/"]' ) is None
assert bsoup . select_one ( '.sf-tp-copy p' ) . get_text ( strip = True ) # 英文兜底文案
assert len ( bsoup . select ( '.sf-tp-about-advantages .sf-tp-feature' )) == 4
assert bsoup . select_one ( 'section.section--dark.sf-tp-about-mission' ) is not None
assert len ( bsoup . select ( '.sf-tp-about-certs .sf-tp-feature' )) == 4
2026-10-23 16:34:28 +08:00
def test_trade_pro_contact_layout_structure ( factory ):
# Task 10f: contact.html 移植 cms 联系页设计(contact.astro + LeadForm.astro,
# 像素基准 dist/hesupipe/en/contact/)后的结构契约。
# 区块序列:PageHero(面包屑 + 封面底图 + 眉题/h1/导语)→ 联系信息/
# 表单双栏(0.85fr 信息列 + 1.25fr 灰底表单卡)→ 富文本正文(body
# 存在才渲染)。cms 的地图带(Google 嵌入 = 公司真实地址数据)不移植
# ——平台无地址字段,虚构地址即假数据;结尾 Cta 由 pro.html 全局
# pro-end-cta 覆盖。
# 无假提交双形态:配置 form_endpoint( HTTPS 双层校验——API 400 拒绝
# 非 HTTPS + make_context 渲染层回退)→ 外部表单(name/email/message
# + pro-honeypot 蜜罐,10b 同款契约,隐藏规则随构建分发);未配置 →
# 不渲染任何 <form>,引导卡 CTA 兜底。联系字段有真实数据才渲染:
# 邮箱 mailto;电话/地址无数据源绝不虚构。
import re
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
entries = factory . get ( f '/api/sites/ { sid } /entries' ) . json ()
contact_id = next ( e [ 'id' ] for e in entries if e [ 'route' ] == '/contact/' )
patched = factory . patch ( f '/api/sites/ { sid } /entries/ { contact_id } ' , json = {
'title' : 'Contact Us' , 'summary' : 'Product enquiries, export logistics and partnership requests.' ,
'body' : '<p>Reach the trade desk for specifications, pricing and delivery schedules.</p>' ,
'cover' : 'https://imgs.example/contact-hall.jpg' })
assert patched . status_code == 200 , patched . text
# HTTPS 双层校验第一层:非 HTTPS 端点被 API 拒绝
bad = factory . put ( f '/api/sites/ { sid } /contact-settings' , json = {
'email' : 'sales@example.org' , 'form_endpoint' : 'http://unsecure.example/lead' })
assert bad . status_code == 400
ok = factory . put ( f '/api/sites/ { sid } /contact-settings' , json = {
'email' : 'sales@example.org' , 'form_endpoint' : 'https://forms.example.org/leads' })
assert ok . status_code == 200 , ok . 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' ]
# ── 形态一(已配置外部表单):hero + 双栏 + 表单 + 蜜罐 ──
html = factory . get ( f '/preview/ { sid } / { bid } /contact/' ) . text
soup = BeautifulSoup ( html , 'html.parser' )
assert len ( soup . find_all ( 'h1' )) == 1
hero = soup . select_one ( 'section.sf-tp-page-hero.sf-tp-contact-hero' )
assert hero is not None
bg = hero . select_one ( '.sf-tp-page-hero-bg' )
assert bg is not None and 'contact-hall.jpg' in bg . get ( 'src' , '' )
current = hero . select_one ( '.sf-tp-breadcrumb-current' )
assert current is not None and current . get_text ( strip = True ) == 'Contact Us'
assert 'Product enquiries' in hero . select_one ( '.sf-tp-page-hero-lead' ) . get_text ()
# 左栏:联系信息(眉题 + h2 + 真实邮箱 mailto + SVG 图标条目)
grid = soup . select_one ( '.sf-tp-contact-grid' )
assert grid is not None
info = grid . select_one ( '.sf-tp-contact-info[data-reveal]' )
assert info is not None and info . select_one ( '.eyebrow' ) and info . find ( 'h2' )
assert info . select_one ( '.sf-tp-contact-item a[href="mailto:sales@example.org"]' ) is not None
items = info . select ( '.sf-tp-contact-item' )
assert len ( items ) >= 2 and all ( i . select_one ( '.icon-circle svg' ) for i in items )
# 电话/地址无数据源 → 不渲染(绝不虚构)
assert 'tel:' not in html
# 右栏:外部表单(全页唯一 form) name/email/message + 隐藏蜜罐
card = grid . select_one ( '.sf-tp-contact-form[data-reveal]' )
assert card is not None
forms = soup . find_all ( 'form' )
assert len ( forms ) == 1
form = forms [ 0 ]
assert form . get ( 'action' ) == 'https://forms.example.org/leads' and form . get ( 'method' ) == 'POST'
for name in ( 'name' , 'email' , 'message' ):
assert form . select_one ( f '[name= { name } ]' ) is not None , name
honeypot = form . select_one ( 'input.pro-honeypot' )
assert honeypot is not None and honeypot . get ( 'aria-hidden' ) == 'true' and honeypot . get ( 'tabindex' ) == '-1'
# 蜜罐隐藏规则随构建分发(10b 修复轮的 .pro-honeypot CSS)
pro_css = factory . get ( f '/preview/ { sid } / { bid } /assets/pro.css' ) . text
assert '.sf-theme-trade-pro .pro-honeypot' in pro_css and 'left:-9999px' in pro_css
# 正文:body 存在才渲染(10d 排版层双挂)
body = soup . select_one ( '.sf-tp-contact-body .pro-rich' )
assert body is not None and 'trade desk' in body . get_text ()
# 交互箭头一律 SVG(Task 9 契约)
assert not re . search ( r '[←→↗↘⌕▾]' , '' . join ( a . get_text () for a in soup . find_all ( 'a' )))
# ── 形态二(未配置):无任何 <form>,引导卡兜底 + 无虚构联系方式 ──
sid2 = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid2 } /bootstrap' ) . status_code == 200
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' ]
bare = factory . get ( f '/preview/ { sid2 } / { bid2 } /contact/' )
assert bare . status_code == 200 , bare . text [: 300 ]
bsoup = BeautifulSoup ( bare . text , 'html.parser' )
assert len ( bsoup . find_all ( 'form' )) == 0 # 无假提交:未配置即不渲染任何表单
assert 'mailto:' not in bare . text # 无邮箱数据 → 无 mailto(含页脚)
assert 'tel:' not in bare . text # 绝不虚构电话
card2 = bsoup . select_one ( '.sf-tp-contact-form' )
assert card2 is not None # 引导卡仍在(CTA 引导)
assert card2 . select_one ( 'a.sf-tp-btn[href$="/products/"]' ) is not None
assert len ( bsoup . find_all ( 'h1' )) == 1
2026-10-23 16:59:52 +08:00
def test_trade_pro_case_layout_structure ( factory ):
# Task 10g: case.html 移植 cms 案例详情设计(projects/[slug].astro,像素基准
# dist/hesupipe/en/projects/<slug>/)后的结构契约。
# 区块序列:PageHero(面包屑 Home › Cases › 标题 + 封面底图 + 眉题/h1/导语)
# → detail-grid 双栏(1.2fr 文案列:Project Overview 眉题 + h2 + ● location/
# industry 行 + 富文本正文;0.8fr sticky 侧列:主图 + 3 列相册缩略图 +
# Results 成果卡)→ Recommended Products(产品卡,空则隐藏)→ Other
# Projects( groups['case'] 排除自身,空则隐藏)。页尾 Cta 由 pro.html 的
# pro-end-cta 覆盖。cms 的结构化字段(challenge/solution/results 词典兜底)
# 平台无对应数据源:正文分段由富文本自身标题承担,成果卡由 fields.results
# 开放字段驱动(分号/换行分隔要点),空则整段隐藏——绝不渲染词典兜底文案。
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' : 'case' , 'title' : 'City Drinking Water Project' , 'route' : '/case/city-water/' ,
'cover' : 'https://imgs.example/city-water.jpg' , 'summary' : 'Municipal supply network upgrade in Jakarta.' ,
'body' : '<p>The network demanded reliable joint performance under tropical site conditions.</p><h2>The Solution</h2><p>A complete piping system was delivered and supervised on site.</p>' ,
'meta' : { 'fields' : { 'location' : 'Jakarta, Indonesia' , 'industry' : 'Municipal water supply' ,
'results' : 'Delivered on schedule;Passed third-party inspection' ,
'gallery' : 'https://imgs.example/city-water.jpg; https://imgs.example/city-water-b.jpg; https://imgs.example/city-water-c.jpg' }}})
assert made . status_code in ( 200 , 201 ), made . text
for i in range ( 2 ):
r = factory . post ( f '/api/sites/ { sid } /entries' , json = {
'type' : 'case' , 'title' : f 'Metro Water Supply { i } ' , 'route' : f '/case/metro- { i } /' ,
'summary' : f 'Reference project { i } .' })
assert r . status_code in ( 200 , 201 ), r . text
# 1 个产品驱动 Recommended Products 区
prod = factory . post ( f '/api/sites/ { sid } /entries' , json = {
'type' : 'product' , 'title' : 'HDPE Pipe PE100' , 'summary' : 'Pressure pipe for water supply.' })
assert prod . status_code in ( 200 , 201 ), prod . 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 } /case/city-water/' )
assert r . status_code == 200 , r . text [: 400 ]
html = r . text
soup = BeautifulSoup ( html , 'html.parser' )
# hero:封面底图 + 面包屑(Home › Cases › 标题)+ 眉题 + 唯一 h1
hero = soup . select_one ( 'section.sf-tp-page-hero.sf-tp-case-hero' )
assert hero is not None
bg = hero . select_one ( '.sf-tp-page-hero-bg' )
assert bg is not None and 'city-water.jpg' in bg . get ( 'src' , '' )
assert hero . select_one ( '.sf-tp-breadcrumb a[href$="/cases/"]' )
current = hero . select_one ( '.sf-tp-breadcrumb-current' )
assert current is not None and current . get_text ( strip = True ) == 'City Drinking Water Project'
assert hero . select_one ( '.sf-tp-page-hero-copy[data-reveal] h1' )
assert 'Municipal supply network upgrade' in hero . select_one ( '.sf-tp-page-hero-lead' ) . get_text ()
assert len ( soup . find_all ( 'h1' )) == 1
# detail 双栏:文案列(概览眉题 + h2 + ● location/industry 行 + 富文本正文)
grid = soup . select_one ( '.sf-tp-case-grid' )
assert grid is not None
copy = grid . select_one ( '.sf-tp-case-copy' )
assert copy is not None and 'project overview' in copy . select_one ( '.eyebrow' ) . get_text () . lower ()
assert copy . find ( 'h2' ) is not None and 'City Drinking Water' in copy . find ( 'h2' ) . get_text ()
locations = copy . select ( '.sf-tp-case-location' )
assert len ( locations ) == 2 # location + industry( blueprints 的 case 字段)
assert 'Jakarta, Indonesia' in locations [ 0 ] . get_text ()
assert 'Municipal water supply' in locations [ 1 ] . get_text ()
body = copy . select_one ( '.sf-tp-case-body .pro-rich' )
assert body is not None and 'The Solution' in body . get_text ()
# 侧列:主图 gallery[0] + 全量相册缩略图(cms 同款含首图)+ Results 成果卡
side = grid . select_one ( '.sf-tp-case-side' )
assert side is not None
photo = side . select_one ( '.sf-tp-photo img' )
assert photo is not None and 'city-water.jpg' in photo . get ( 'src' , '' )
thumbs = side . select ( '.sf-tp-case-gallery img' )
assert len ( thumbs ) == 3 # cover 去重 + b + c
results = side . select_one ( '.sf-tp-case-results' )
assert results is not None and 'Results' in results . find ( 'h3' ) . get_text ()
points = results . select ( 'li' )
assert len ( points ) == 2
assert all ( p . select_one ( '.sf-tp-case-check svg' ) for p in points ) # ✓ 为 SVG 图标
assert 'Passed third-party inspection' in points [ 1 ] . get_text ()
# Recommended Products:产品卡(无产品整段隐藏)
products = soup . select_one ( '.sf-tp-case-products' )
assert products is not None
assert any ( 'HDPE Pipe PE100' in c . get_text () for c in products . select ( '.sf-tp-card' ))
# Other Projects:相关案例去自身
related = soup . select_one ( '.sf-tp-case-related' )
assert related is not None
cards = related . select ( '.sf-tp-card' )
assert len ( cards ) == 2
assert all ( 'City Drinking Water' not in c . get_text () for c in cards )
assert related . select_one ( 'a.sf-tp-more[href$="/cases/"] svg' ) # View all projects → SVG 箭头
# 交互箭头一律 SVG(Task 9 契约)
assert not re . search ( r '[←→↗↘⌕▾]' , '' . join ( a . get_text () for a in soup . find_all ( 'a' )))
# 本构建的链接全通(新增 /cases/ 面包屑与相关案例路由不产生断链)
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' ]
# ── 案例列表分支(10c listing 的 case 类走产品卡栅格;10g 补案例口径
# hero 文案:cms projects 列表 OUR PROJECTS,非 PRODUCT CATALOGUE)──
cases_html = factory . get ( f '/preview/ { sid } / { bid } /cases/' ) . text
assert 'OUR PROJECTS' in cases_html
assert 'PRODUCT CATALOGUE' not in cases_html
csoup = BeautifulSoup ( cases_html , 'html.parser' )
assert len ( csoup . select ( '.sf-tp-card-grid--listing .sf-tp-card' )) == 3
# ── 空数据案例(无 cover/gallery/body/fields/其他案例/产品):相册、
# 成果卡、推荐产品、相关案例整段隐藏;占位图保持版式完整 ──
sid2 = site ( factory )
bare = factory . post ( f '/api/sites/ { sid2 } /entries' , json = {
'type' : 'case' , 'title' : 'Reference Project' , 'route' : '/case/reference/' })
assert bare . status_code in ( 200 , 201 ), bare . 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' ]
page2 = factory . get ( f '/preview/ { sid2 } / { bid2 } /case/reference/' )
assert page2 . status_code == 200 , page2 . text [: 300 ]
bsoup = BeautifulSoup ( page2 . text , 'html.parser' )
assert len ( bsoup . find_all ( 'h1' )) == 1
assert bsoup . select_one ( '.sf-tp-page-hero-bg' ) is None
assert bsoup . select_one ( '.sf-tp-case-gallery' ) is None
assert bsoup . select_one ( '.sf-tp-case-results' ) is None
assert bsoup . select_one ( '.sf-tp-case-products' ) is None
assert bsoup . select_one ( '.sf-tp-case-related' ) is None
assert bsoup . select_one ( '.sf-tp-case-side .sf-tp-photo-plain' ) is not None # 无图占位
assert bsoup . select_one ( '.sf-tp-case-location' ) is None # 字段空则行隐藏
2026-10-23 17:25:21 +08:00
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 摘要在导语位
2026-10-23 17:53:14 +08:00
def test_trade_pro_chrome_layout ( factory ):
# Task 10j: cms 页眉/页脚 chrome 视觉还原 + 手机端抽屉导航。
# 设计源 D:/project/cms/runtimes/astro-site-runtime/src/components/
# Header.astro(玻璃吸顶 + 右对齐半粗导航 + 悬停下划线/下拉卡 + 移动
# 右滑抽屉)/ Footer.astro(深色四栏 + 版权条),10j 以纯样式层把平台
# chrome DOM( sf-site-head/pro-header/pro-navigation/pro-footer)重塑为
# cms 设计——平台数据流(site_design 导航/公告/CTA/页脚选项)与 pro.js
# 交互钩子(data-pro-menu/data-pro-catalog/data-pro-mega/pro-nav-disclose)
# 全部保留。抽屉复用上游 V8.21.3 四主题机制(commercial.css 样式段 +
# pro.js 通用逻辑:选择器寻钩子而非主题分支,trade-pro 输出同款类名
# 结构即自动可用);mobile_tabs 为模板驱动,10j 给 trade-pro 补上
# 渲染块后配置即生效(下方发布前后双构建断言)。
import re
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
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' ]
html = factory . get ( f '/preview/ { sid } / { bid } /' ) . text
soup = BeautifulSoup ( html , 'html.parser' )
# ── 平台 chrome 结构与交互钩子保留(数据流不破坏的结构证据)──
header = soup . select_one ( 'header.sf-site-head.pro-header' )
assert header is not None
assert header . select_one ( '.pro-wrap.pro-head-inner' ) is not None
assert header . select_one ( 'a.pro-logo[href$="/"]' ) is not None # /preview/ 重写根相对 href,用后缀匹配
toggle = header . select_one ( 'button.pro-mobile-toggle[data-pro-menu]' )
assert toggle is not None and toggle . get ( 'aria-controls' ) == 'pro-navigation'
nav = header . select_one ( 'nav#pro-navigation.pro-navigation' )
assert nav is not None and nav . get ( 'aria-label' )
# catalog/mega 钩子(aria-controls 对应 mega 面板 id;面板本体按 categories 条件渲染)
catalog = header . select_one ( 'button.pro-catalog-toggle[data-pro-catalog]' )
assert catalog is not None and catalog . get ( 'aria-controls' ) == 'pro-mega-content'
# CTA 双挂 sf-tp-btn( cms 蓝按钮形态)+ chrome.header CTA 数据位
cta = header . select_one ( 'a.pro-header-cta.sf-tp-btn' )
assert cta is not None and cta . get ( 'href' , '' ) . endswith ( '/contact/' )
# 公告条数据位仍在(未发布 site_design 时渲染英文兜底文案)
assert soup . select_one ( '.pro-utility .pro-wrap' ) is not None
# ── 英文兜底导航(tp_nav):7 项默认栏目全渲染(尾部可跟 Search 链接——
# chrome.header.show_search 数据位默认开)──
nav_links = nav . select ( ':scope > a, :scope > .pro-nav-group > a' )
nav_text = [ a . get_text ( strip = True ) for a in nav_links ]
assert nav_text [: 7 ] == [ 'Home' , 'Products' , 'Brands' , 'News' , 'Cases' , 'About' , 'Contact' ]
assert nav_text [ 7 :] in ([], [ 'Search' ])
# ── 页脚:平台 DOM + cms 栏目标题(Quick Links 随 footer_nav 数据流)
footer = soup . select_one ( 'footer.sf-site-foot.pro-footer' )
assert footer is not None
assert footer . select_one ( '.pro-footer-top' ) is not None
heading = footer . select_one ( '.pro-footer-links h3' )
assert heading is not None and heading . get_text ( strip = True ) == 'Quick Links'
footer_nav = footer . select ( '.pro-footer-links ul li a' )
assert len ( footer_nav ) == 7 and footer_nav [ 0 ] . get_text ( strip = True ) == 'Home'
assert footer . select_one ( '.pro-footer-bottom' ) is not None
# ── 10j 视觉层随构建分发(pro.css: cms chrome 映射的样式证据)──
pro_css = factory . get ( f '/preview/ { sid } / { bid } /assets/pro.css' ) . text
assert 'Task 10j' in pro_css
# 玻璃拟态吸顶(Header.astro .site-header)
assert re . search ( r '\.sf-theme-trade-pro \.pro-header\{[^}]*position:sticky' , pro_css )
assert 'backdrop-filter:blur(10px)' in pro_css
# 导航:cms 右对齐半粗 + 悬停下划线(.nav-links a 的 border-block-end 方案)
assert re . search ( r '\.sf-theme-trade-pro \.pro-navigation \.pro-nav-group>a[^{]*\{[^}]*border-block-end' , pro_css )
assert 'var(--sf-tp-color-border-brand)' in pro_css
# 下拉卡(.sub-menu):白卡 + 发丝边 + radius-sm + shadow-md 的令牌引用
assert re . search ( r '\.sf-theme-trade-pro \.pro-nav-children\{[^}]*var\(--sf-tp-color-surface-default\)' , pro_css )
# 深色页脚(.site-footer 令牌桥:surface-inverse + 版权条 border-inverse)
assert re . search ( r '\.sf-theme-trade-pro \.pro-footer\{[^}]*var\(--sf-tp-color-surface-inverse\)' , pro_css )
assert 'var(--sf-tp-color-border-inverse)' in pro_css
# 抽屉品牌化变量桥:平台 drawer 样式段消费 --pro-* 变量,10j 注入 cms 令牌
assert '--pro-background:var(--sf-tp-color-surface-default)' in pro_css
# ── 上游抽屉机制复用(commercial.css/pro.js 随构建分发 + 安全区适配)──
assert 'assets/commercial.css' in html and 'assets/pro.js' in html
commercial = factory . get ( f '/preview/ { sid } / { bid } /assets/commercial.css' ) . text
assert '.sf-mobile-drawer-backdrop' in commercial and 'sf-drawer-open' in commercial
assert 'env(safe-area-inset-bottom' in commercial # 抽屉/底部 tabs 的安全区适配
pro_js = factory . get ( f '/preview/ { sid } / { bid } /assets/pro.js' ) . text
assert 'sf-drawer-header' in pro_js and 'sf-drawer-extra' in pro_js and 'Escape' in pro_js
# ── mobile_tabs:发布 site_design 前不渲染,发布后渲染(含 menu 动作开抽屉)──
assert 'data-mobile-tabs' not in html
import copy
from app import site_design_v87
config = copy . deepcopy ( site_design_v87 . DEFAULTS )
config [ 'mobile_tabs' ] = { 'enabled' : True , 'items' : [
{ 'title' : 'Home' , 'icon' : 'home' , 'route' : '/' , 'action' : 'link' },
{ 'title' : 'Products' , 'icon' : 'products' , 'route' : '/products/' , 'action' : 'link' },
{ 'title' : 'Menu' , 'icon' : 'menu' , 'route' : '/' , 'action' : 'menu' }]}
url = f '/api/sites/ { sid } /site-design'
assert factory . put ( url , json = { 'config' : config , 'expected_revision' : 0 }) . status_code == 200
assert factory . post ( url + '/publish' , json = { 'expected_revision' : 1 }) . status_code == 200
bid2 = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
html2 = factory . get ( f '/preview/ { sid } / { bid2 } /' ) . text
assert 'data-mobile-tabs' in html2 and 'data-mobile-open-menu' in html2
tabs = BeautifulSoup ( html2 , 'html.parser' ) . select ( '.sf-mobile-tabs .sf-mobile-tab' )
assert len ( tabs ) == 3
assert [ t . select_one ( '.sf-tab-label' ) . get_text ( strip = True ) for t in tabs ] == [ 'Home' , 'Products' , 'Menu' ]
assert tabs [ 0 ] . select_one ( '.sf-tab-icon' ) is not None # 图标位渲染
# 发布后 chrome_is_custom 生效:公告条走配置数据位(announcement 文案 +
# show_announcement 开关)。publish 会清空草稿 → 新 PUT 从 revision 0 重新开始
announced = { ** config , 'header' : { ** config [ 'header' ], 'announcement' : 'Global sourcing update' , 'show_announcement' : True }}
assert factory . put ( url , json = { 'config' : announced , 'expected_revision' : 0 }) . status_code == 200
assert factory . post ( url + '/publish' , json = { 'expected_revision' : 1 }) . status_code == 200
bid3 = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
html3 = factory . get ( f '/preview/ { sid } / { bid3 } /' ) . text
assert 'Global sourcing update' in html3
# 本构建链接全通(chrome 改动不产生断链)
report = factory . get ( f '/api/sites/ { sid } /builds/ { bid3 } /report' ) . json ()
assert not report [ 'errors' ] and not report [ 'broken_links' ]
2026-10-22 18:51:27 +08:00
def test_frontend_entries_include_trade_pro ():
from pathlib import Path
base = Path ( __file__ ) . resolve () . parent . parent
for rel , needle in [
( 'app/templates/admin.html' , 'value="trade-pro"' ),
( 'app/templates/workspace.html' , 'value="trade-pro"' ),
( 'app/templates/theme-gallery.html' , 'value="trade-pro"' ),
( 'app/static/workspace.js' , "'trade-pro'" ),
( 'app/static/admin.js' , "'trade-pro'" ),
( 'app/static/factory-v2.js' , "'trade-pro'" ),
]:
assert needle in ( base / rel ) . read_text ( encoding = 'utf-8' ), rel
def test_theme_color_map_covers_trade_pro ():
# theme_pro 渲染上下文经映射表输出 trade-pro 的 theme-color( pro.html 已改回变量)
from app import theme_pro
src = ( Path ( theme_pro . __file__ ) . resolve () . parent . parent / 'themes' / 'trade-pro' / 'pro.html' ) . read_text ( encoding = 'utf-8' )
assert '{{theme_color}}' in src # 不再写死
# 运行时断言:轻量直渲(不经 fixture 构建),映射表 + 变量路径输出 #0b3d91。
# 全量构建路径由 test_trade_pro_builds_real_site 的既有 meta 断言持续覆盖。
html = theme_pro . render ( 'trade-pro' , { 'theme' : 'trade-pro' , 'name' : 'Go Global' },
{ 'title' : 'Home' , 'route' : '/' }, [], mode = 'home' )
assert '<meta name="theme-color" content="#0b3d91"' in html
2026-10-23 08:35:52 +08:00
def test_trade_pro_icon_macro_library ():
# Task 9: _professional_components.html 提供 icon(name, size, cls) 内联 SVG 宏。
# 纯服务端输出:无 JS 依赖,装饰性图标 aria-hidden + focusable=false,
# stroke=currentColor 随文字颜色走。
from app import theme_pro
env = theme_pro . env_for ( 'trade-pro' )
tpl = env . from_string ( "{ % f rom '_professional_components.html' import icon %}{{ icon('arrow-right') }}" )
out = tpl . render ()
assert '<svg' in out and 'aria-hidden="true"' in out and 'currentColor' in out
# 未知图标名回退 arrow-right(不抛错),且回退输出与显式 arrow-right 一致
tpl2 = env . from_string ( "{ % f rom '_professional_components.html' import icon %}{{ icon('no-such') }}" )
fallback = tpl2 . render ()
assert '<svg' in fallback
assert fallback == tpl . render ()
def test_trade_pro_templates_have_no_bare_text_arrows ():
# Task 9:交互元素的箭头必须是 SVG 图标,不得残留裸文本 → / ›。
# 判定逻辑:先剔除 Jinja 注释 {# #} 内的说明文字;› 的豁免通道是
# 'chevron' 同现(下拉指示语义)——非图标用途(如版权分隔符)可走该
# 白名单,当前主题模板应一个裸箭头都没有。
from pathlib import Path
import re
root = Path ( __file__ ) . resolve () . parent . parent / 'themes' / 'trade-pro'
offenders = []
for f in root . rglob ( '*.html' ):
t = f . read_text ( encoding = 'utf-8' )
body = re . sub ( r '\{#.*?#\}' , '' , t , flags = re . S )
2026-10-23 09:00:50 +08:00
if any ( ch in body for ch in '→←↗↘⌕▾' ):
2026-10-23 08:35:52 +08:00
offenders . append ( str ( f . relative_to ( root )))
2026-10-23 09:00:50 +08:00
# › 豁免通道收紧为行级:同行的 chevron 语义才放行
for line in body . splitlines ():
if '› ' in line and 'chevron' not in line :
offenders . append ( f ' { f . relative_to ( root ) } :› ' )
2026-10-23 08:35:52 +08:00
assert offenders == []
2026-10-23 09:00:50 +08:00
def test_site_html_arrow_matches_macro_library ():
# 双源防护:site.html 的自包含 tp_arrow 必须与宏库 arrow-right 的 path 一致
from pathlib import Path
import re
root = Path ( __file__ ) . resolve () . parent . parent / 'themes' / 'trade-pro'
macro = ( root / '_professional_components.html' ) . read_text ( encoding = 'utf-8' )
site = ( root / 'site.html' ) . read_text ( encoding = 'utf-8' )
m = re . search ( r "'arrow-right'\s*:\s*'([^']+)'" , macro )
s = re . search ( r "tp_arrow.*?d= \" ([^ \" ]+) \" " , site , flags = re . S )
assert m and s , 'icon path sources not found'
assert m . group ( 1 ) == s . group ( 1 ), 'site.html tp_arrow drifted from macro library arrow-right'
2026-10-28 16:07:39 +08:00
def test_trade_pro_blocks_visual_bridge ( factory ):
# Task 13a:区块样式全量桥接——平台区块(blueprints.render_blocks 的 sf-*
# DOM)在 trade-pro 下获得 sf-tp-* 模板级视觉。三层契约:
# 1) 分发:themes/trade-pro/blocks.css 原文随构建分发(copy_blocks_css
# 替代平台版),构建页 <link> 指向 assets/blocks.css;
# 2) 结构:多类型区块页(layout_mode=replace 纯区块渲染)输出全部 sf-*
# 区块 DOM, pro.js 筛选数据钩子(data-category/data-brand/
# [data-sf-filterable])与 YouTube 白名单改写保留;
# 3) 视觉:.sf-theme-trade-pro 作用域的 sf-* 覆盖规则齐全、--sf-tp-*
# 令牌被引用、模板断点(900/620)齐备,平台基础层(slider/布局栅格/
# 设备断点 820)完整保留,且不触碰编辑器画布钩子(data-block /
# sf-canvas-selected / 区块级 outline)。
import re
from app import blueprints
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
img = 'https://imgs.example/bridge.jpg'
blocks = [
blueprints . new_block ( 'hero' , { 'title' : 'Global Sourcing Hub' , 'subtitle' : 'TRADE PRO' ,
'description' : 'Verified manufacturers.' , 'image' : img , 'button' : 'Explore' }),
blueprints . new_block ( 'stats' , { 'items' : [{ 'value' : '12+' , 'label' : 'Years' }, { 'value' : '40+' , 'label' : 'Countries' },
{ 'value' : '98%' , 'label' : 'On time' }, { 'value' : '120' , 'label' : 'SKUs' }]}),
blueprints . new_block ( 'features' , { 'title' : 'Why Us' , 'items' : [{ 'title' : 'QC' , 'text' : 'Inspection.' },
{ 'title' : 'Logistics' , 'text' : 'FOB.' }, { 'title' : 'Support' , 'text' : 'English desk.' }]}),
blueprints . new_block ( 'collection' , { 'title' : 'Featured' , 'subtitle' : 'PRODUCTS' , 'source' : 'product' , 'limit' : 4 }),
blueprints . new_block ( 'image_text' , { 'title' : 'Factory Direct' , 'subtitle' : 'ABOUT' ,
'description' : 'Own production lines.' , 'image' : img }),
blueprints . new_block ( 'cta' , { 'title' : 'Request a Quote' , 'subtitle' : 'Send specifications.' }),
blueprints . new_block ( 'gallery' , { 'title' : 'Workshop' , 'images' : [{ 'url' : img , 'alt' : 'Line' },
{ 'url' : img , 'alt' : 'Goods' }, { 'url' : img , 'alt' : 'Lab' }]}),
blueprints . new_block ( 'faq' , { 'title' : 'FAQ' , 'items' : [{ 'question' : 'MOQ?' , 'answer' : 'One container.' }]}),
blueprints . new_block ( 'richtext' , { 'title' : 'Terms' , 'body' : '<p>Incoterms 2020 apply.</p>' }),
blueprints . new_block ( 'carousel' , { 'title' : 'Showcase' , 'items' : [{ 'image' : img , 'alt' : 'Shot' , 'caption' : 'Line 1' }]}),
blueprints . new_block ( 'video' , { 'title' : 'Plant Tour' , 'url' : 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }),
blueprints . new_block ( 'pricing' , { 'title' : 'Plans' , 'items' : [{ 'name' : 'Bulk' , 'price' : 'FOB' , 'features' : [ 'QC' , 'Packing' ]}]}),
blueprints . new_block ( 'tabs' , { 'title' : 'Details' , 'items' : [{ 'label' : 'Specs' , 'text' : 'PE100 raw material.' }]}),
blueprints . new_block ( 'spacer' , { 'height' : 24 }),
]
for i in range ( 2 ):
made = factory . post ( f '/api/sites/ { sid } /entries' , json = {
'type' : 'product' , 'title' : f 'Bridge Valve { i } ' , 'route' : f '/product/bridge- { i } /' ,
'cover' : img , 'summary' : 'Bridge card.' , 'meta' : { 'fields' : { 'brand' : 'Quanta' , 'category' : 'Valves' }}})
assert made . status_code in ( 200 , 201 ), made . text
page = factory . post ( f '/api/sites/ { sid } /entries' , json = {
'type' : 'page' , 'title' : 'Bridge Demo' , 'route' : '/bridge-demo/' ,
'meta' : { 'page_template' : 'standard' , 'layout_mode' : 'replace' , 'blocks' : blocks }})
assert page . status_code in ( 200 , 201 ), page . 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' ]
# ── 1) 分发:主题版原文(非平台版)随构建输出 ──
html = factory . get ( f '/preview/ { sid } / { bid } /bridge-demo/' ) . text
assert 'assets/blocks.css' in html
served = factory . get ( f '/preview/ { sid } / { bid } /assets/blocks.css' ) . text
theme_blocks_css = ( ROOT / 'themes' / 'trade-pro' / 'blocks.css' ) . read_text ( encoding = 'utf-8' )
platform_blocks_css = ( ROOT / 'app' / 'static' / 'blocks.css' ) . read_text ( encoding = 'utf-8' )
assert served == theme_blocks_css and served != platform_blocks_css
# ── 2) 结构:全部区块类型渲染 + 平台数据钩子保留 ──
soup = BeautifulSoup ( html , 'html.parser' )
assert 'sf-theme-trade-pro' in ( soup . body . get ( 'class' ) or [])
assert soup . select_one ( 'main.sf-canvas.pro-custom-content' ) is not None
for sel in ( 'section.sf-hero .sf-hero-grid .sf-hero-copy h1' , 'section.sf-hero .sf-hero-img img' ,
'section.sf-stats .sf-stats strong' , 'section.sf-features .sf-feature h3' ,
'section.sf-collection .sf-item-copy h3' , 'section.sf-image_text .sf-image-side img' ,
'section.sf-cta .sf-cta-wrap .sf-button' , 'section.sf-gallery .sf-gallery img' ,
'section.sf-faq details summary' , 'section.sf-richtext .sf-prose' ,
'section.sf-carousel .sf-slides figure.sf-slide' , 'section.sf-video .sf-video iframe' ,
'section.sf-pricing .sf-price strong' , 'section.sf-tabs .sf-tab-list details.sf-tab' ,
'div.sf-spacer' ):
assert soup . select_one ( sel ) is not None , sel
cards = soup . select ( '.sf-collection[data-sf-filterable] a.sf-item[data-category][data-brand]' )
assert len ( cards ) == 2 and cards [ 0 ][ 'data-brand' ] == 'Quanta'
assert 'youtube-nocookie.com/embed/dQw4w9WgXcQ' in html
# ── 3) 视觉桥接:作用域规则 + 令牌 + 断点 + 基础层红线 ──
for scoped in ( '.sf-theme-trade-pro .sf-canvas .sf-section.sf-hero{' ,
'.sf-theme-trade-pro .sf-feature{' , '.sf-theme-trade-pro .sf-item{' ,
'.sf-theme-trade-pro .sf-image-text{' , '.sf-theme-trade-pro .sf-container .sf-stats{' ,
'.sf-theme-trade-pro .sf-canvas .sf-section.sf-cta{' , '.sf-theme-trade-pro .sf-gallery{' ,
'.sf-theme-trade-pro .sf-prose{' , '.sf-theme-trade-pro .sf-slides{' ,
'.sf-theme-trade-pro .sf-container .sf-video{' , '.sf-theme-trade-pro .sf-price{' ,
'.sf-theme-trade-pro .sf-button{' ):
assert scoped in served , scoped
for token in ( '--sf-tp-color-surface-inverse' , '--sf-tp-color-brand-primary' , '--sf-tp-color-brand-primary-deep' ,
'--sf-tp-color-text-inverse' , '--sf-tp-color-text-brand' , '--sf-tp-color-text-muted' ,
'--sf-tp-color-surface-muted' , '--sf-tp-color-border-subtle' , '--sf-tp-radius-md' ,
'--sf-tp-shadow-md' , '--sf-tp-ratio-landscape' , '--sf-tp-space-section-y' ,
'--sf-tp-font-size-2xl' , '--sf-tp-button-height-md' , '--sf-tp-duration-fast' ,
'--sf-tp-easing-standard' ):
assert f 'var( { token } )' in served , token
assert len ( set ( re . findall ( r 'var\(--sf-tp-[a-z0-9-]+\)' , served ))) >= 25
assert '@media (max-width: 900px)' in served and '@media (max-width: 620px)' in served
# 平台功能层完整保留:slider / 布局栅格 / 设备断点(responsive.py 依赖 820/540 语义)
for base_rule in ( '.sf-hero-track{' , '.sf-layout-grid{' , '.sf-hero-controls{' , '@media(max-width:820px)' ):
assert base_rule in served , base_rule
# 编辑器画布与区块 outline 红线:主题层不得触碰选择器钩子与区块级 outline
assert 'data-block' not in served and 'sf-canvas-selected' not in served
assert not re . search ( r '\.sf-section\.sf-[a-z-]+\s*\{[^}]*outline' , served )
# 本构建链接全通
report = factory . get ( f '/api/sites/ { sid } /builds/ { bid } /report' ) . json ()
assert not report [ 'errors' ] and not report [ 'broken_links' ]
2026-10-28 18:11:32 +08:00
def test_trade_pro_home_block_driven ( factory ):
# Task 13b:首页从模板驱动切换为区块驱动(layout_mode=replace)。
# 序列对照 home.html 模板设计:hero → trust_bar(新增)→ image_text+stats →
# collection(product,4) → applications_band(新增)→ features →
# collection(post,3) → cta。写入走真实 studio-draft 链路(PUT 草稿 →
# publish 发布 → 构建),与运维脚本 scripts/setup_home_blocks.py 同一条
# 代码路径。home.html 模板文件保留(其他站点仍可用),仅本站首页 replace。
import io
from app import blueprints
sid = site ( factory )
assert factory . post ( f '/api/factory/sites/ { sid } /bootstrap' ) . status_code == 200
img = 'https://imgs.example/hesu-plant.jpg'
batch = [{ 'type' : 'product' , 'title' : f 'HDPE Pipe PE100 { i } ' ,
'summary' : f 'Pressure pipe { i } for municipal water supply.' , 'cover' : img ,
'meta' : { 'fields' : { 'brand' : 'HESU' , 'category' : 'Pipes' }}} for i in range ( 4 )]
batch += [{ 'type' : 'post' , 'title' : f 'Export Update { i } ' ,
'summary' : f 'Dock loading report { i } .' } for i in range ( 3 )]
payload = json . dumps ( batch ) . encode ( 'utf-8' )
imported = factory . post ( f '/api/sites/ { sid } /import' ,
files = { 'file' : ( 'home-batch.json' , io . BytesIO ( payload ), 'application/json' )})
assert imported . status_code == 200 , imported . text [: 300 ]
assert factory . put ( f '/api/factory/sites/ { sid } /theme' , json = { 'theme' : 'trade-pro' , 'version' : '1.0.0' }) . status_code == 200
home = next ( e for e in factory . get ( f '/api/sites/ { sid } /entries' ) . json () if e [ 'route' ] == '/' )
blocks = [
blueprints . new_block ( 'hero' , { 'title' : 'Reliable piping systems, exported worldwide' ,
'subtitle' : 'GLOBAL SOURCING · MADE RELIABLE' ,
'description' : 'Specifications, certification and packaging published with every listing.' ,
'image' : img , 'button' : 'Browse products' , 'href' : '/products/' }),
blueprints . new_block ( 'trust_bar' , {}),
blueprints . new_block ( 'image_text' , { 'title' : 'One partner from inquiry to delivery.' ,
'subtitle' : 'ABOUT US' , 'image' : img ,
'description' : 'Product specifications, quality control and export documentation in one place.' ,
'button' : 'About our company' , 'href' : '/about/' }),
blueprints . new_block ( 'stats' , { 'items' : [{ 'value' : '48' , 'label' : 'Products' },
{ 'value' : '12' , 'label' : 'Categories' },
{ 'value' : '30' , 'label' : 'Markets served' },
{ 'value' : '10' , 'label' : 'Export years' }]}),
blueprints . new_block ( 'collection' , { 'title' : 'Featured products' , 'subtitle' : 'TRADE CATALOGUE' ,
'source' : 'product' , 'limit' : 4 , 'columns' : 4 , 'image_fit' : 'cover' ,
'button' : 'Browse all products' , 'href' : '/products/' }),
blueprints . new_block ( 'applications_band' , {}),
blueprints . new_block ( 'features' , { 'title' : 'Why buyers stay with us' , 'items' : [
{ 'title' : 'Verified sourcing' , 'text' : 'Suppliers audited against your specifications before the first sample.' },
{ 'title' : 'Strict quality control' , 'text' : 'Pre-shipment inspection with photographic evidence for every batch.' },
{ 'title' : 'Export documentation' , 'text' : 'Certification, packing lists and customs papers prepared in-house.' },
{ 'title' : 'Dedicated support' , 'text' : 'One trade desk contact from inquiry to container loading.' }]}),
blueprints . new_block ( 'collection' , { 'title' : 'Latest updates' , 'subtitle' : 'COMPANY NEWS' ,
'source' : 'post' , 'limit' : 3 , 'columns' : 3 , 'image_fit' : 'cover' ,
'button' : 'View all news' , 'href' : '/news/' }),
blueprints . new_block ( 'cta' , { 'title' : 'Send one inquiry, get a matched quote.' ,
'subtitle' : 'Share your specifications and target market; our trade desk replies within one business day.' ,
'button' : 'Request a quote' , 'href' : '/contact/' }),
]
put = factory . put ( f '/api/sites/ { sid } /pages/ { home [ "id" ] } /studio-draft' ,
json = { 'blocks' : blocks , 'page_template' : 'home' , 'layout_mode' : 'replace' , 'expected_revision' : 0 })
assert put . status_code == 200 , put . text
pub = factory . post ( f '/api/sites/ { sid } /pages/ { home [ "id" ] } /studio-draft/publish' , json = { 'expected_revision' : 1 })
assert pub . status_code == 200 , pub . text
state = factory . get ( f '/api/sites/ { sid } /pages/ { home [ "id" ] } /studio-draft' ) . json ()
assert state [ 'state' ] == 'published' and state [ 'layout_mode' ] == 'replace'
bid = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
html = factory . get ( f '/preview/ { sid } / { bid } /' ) . text
soup = BeautifulSoup ( html , 'html.parser' )
# ── 8 种区块全部在场(在画布容器内,编辑器结构契约不破坏)──
canvas = soup . select_one ( 'main.sf-canvas.pro-custom-content' )
assert canvas is not None
for sel in ( 'section.sf-hero' , 'section.sf-trust_bar' , 'section.sf-image_text' , 'section.sf-stats' ,
'section.sf-applications_band' , 'section.sf-features' , 'section.sf-cta' ):
assert canvas . select_one ( sel ) is not None , sel
collections = canvas . select ( 'section.sf-collection' )
assert len ( collections ) == 2
assert len ( collections [ 0 ] . select ( 'a.sf-item' )) == 4 # 产品精选 4 卡
assert len ( collections [ 1 ] . select ( 'a.sf-item' )) == 3 # 新闻 3 卡
assert 'HDPE Pipe PE100 0' in collections [ 0 ] . get_text ()
assert 'Export Update' in collections [ 1 ] . get_text ()
# ── 新区块结构契约:信任条(icon-circle+标题+说明+分隔线)与应用带(2×2 瓦片)──
trust = canvas . select ( '.sf-trust-bar .sf-trust-item' )
assert len ( trust ) == 4
assert all ( t . select_one ( '.sf-trust-icon' ) and t . find ( 'strong' ) and t . select_one ( '.sf-trust-text' ) for t in trust )
assert 'Certified quality' in trust [ 0 ] . get_text () and 'Responsive service' in trust [ 3 ] . get_text ()
tiles = canvas . select ( '.sf-applications-band .sf-app-tile' )
assert len ( tiles ) == 4
assert all ( t . select_one ( '.sf-app-icon' ) and t . find ( 'h3' ) and t . find ( 'p' ) for t in tiles )
assert 'Water supply' in tiles [ 0 ] . get_text () and 'Mining' in tiles [ 3 ] . get_text ()
# ── 模板不再渲染:home.html 的模板级标记全数缺席(chrome 的 sf-tp-btn 不在此列)──
for marker in ( 'sf-tp-hero' , 'sf-tp-trustbar' , 'sf-tp-two-col' , 'sf-tp-card-grid' ,
'sf-tp-applications' , 'sf-tp-feature-grid' , 'sf-tp-cta' ):
assert marker not in html , marker
assert len ( soup . find_all ( 'h1' )) == 1 # 区块 hero 提供唯一 h1,模板缺席不重复
# ── 桥接层语汇:新区块的 trade-pro 视觉规则 + sf-tp 令牌随构建分发 ──
served = factory . get ( f '/preview/ { sid } / { bid } /assets/blocks.css' ) . text
for rule in ( '.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{' ,
'.sf-theme-trade-pro .sf-trust-item{' ,
'.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{' ,
'.sf-theme-trade-pro .sf-app-tile{' ):
assert rule in served , rule
assert 'var(--sf-tp-color-surface-inverse)' in served
assert 'var(--sf-tp-color-text-brand)' in served
# 编辑器画布红线(13a 契约延续):新区块样式不触碰画布钩子
assert 'data-block' not in served and 'sf-canvas-selected' not in served
# ── 构建报告零错误 ──
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' ]
2026-10-22 17:49:12 +08:00
def test_theme_blocks_css_overrides_platform ( tmp_path ):
from app import builder
dist = tmp_path / 'assets' ; dist . mkdir ()
theme_dir = tmp_path / 'theme' ; theme_dir . mkdir ()
( theme_dir / 'blocks.css' ) . write_text ( '/* theme version */' , encoding = 'utf-8' )
builder . copy_blocks_css ( theme_dir , dist )
assert ( dist / 'blocks.css' ) . read_text ( encoding = 'utf-8' ) == '/* theme version */'
2026-10-22 17:58:05 +08:00
# 无主题版时回落平台版(精确断言内容一致)
2026-10-22 17:49:12 +08:00
dist2 = tmp_path / 'assets2' ; dist2 . mkdir ()
builder . copy_blocks_css ( tmp_path / 'nonexistent' , dist2 )
2026-10-22 17:58:05 +08:00
platform = ( builder . ROOT / 'app' / 'static' / 'blocks.css' ) . read_text ( encoding = 'utf-8' )
assert ( dist2 / 'blocks.css' ) . read_text ( encoding = 'utf-8' ) == platform