Files
cms-SiteForge/tests/test_theme_registry.py
T
浅念 391d22f1a8 feat: refine trade-pro variant pages (brand/service/faq/search/notfound)
Task 11 (layout port finale): rebuild the five thin variant pages in the
sf-tp design language, informed by upstream industrial/automotive
1.3.0/1.4.0 information architecture.

- brand: page-hero (breadcrumb Home > Brands > name, cover bg, anchor
  button) + related-product card grid (fields.brand == page title) +
  brand profile body only when authored; derived placeholder summary
  excluded, empty brands get an empty state, never fabricated intros.
- service: page-hero + 1.2/0.8 detail grid (article-body typography +
  sticky CTA card with contact/back links); empty body collapses to a
  centered solo column with the CTA card kept.
- faq: page-hero + article-body for the rich-text body (platform has no
  faq content type; sanitize strips details/summary so the typography
  layer carries it, block-driven details coexist via sf-canvas) +
  dark contact-guidance card; empty body swaps to an empty state.
- search: adopts the same platform mechanism as all four upstream
  themes (site-runtime.js ?q= client search over dist-root
  search-index.json); #sf-search/#sf-search-results/#sf-search-count
  hooks and .sf-search-page kept as JS contract, panel and injected
  result cards restyled via scoped cms-token overrides.
- notfound: branded dark-band 404 (icon macro, giant 404 h1, English
  copy, back-home + browse-products buttons); no end-cta.

CSS: Task 11 block appended to pro.css (new sf-tp-* classes only, own
900/620 media queries; shared hero/grid/typography responsive rules
come from existing 10a/10c/10d blocks; search overrides beat
factory.css by specificity without touching hooks).

Tests: test_trade_pro_variant_pages_structure covers brand fixture
(related filtering, empty forms), service/faq body-vs-empty, search
mechanism contract + noindex + index asset, 404 structure, v3 spot
checks (single h1, chrome, SVG arrows) and clean build report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 16:32:15 +08:00

1166 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Theme discovery must be a single source of truth."""
import json
from pathlib import Path
from bs4 import BeautifulSoup
from app import core, theme_pro, theme_store
from test_factory_v2 import factory, site # noqa: F401 -- fixture + helper reused by trade-pro tests
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():
# Tripwire: the three names must stay the same object. Fails only if someone
# reintroduces a hardcoded copy of the list anywhere.
assert set(core.THEMES) == set(theme_pro.THEMES) == set(theme_store.BUILTIN)
assert isinstance(core.THEMES, tuple)
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()
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
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()
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
# 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
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
assert '>Products<' in home.text # English fallback nav actually rendered
assert '>首页<' not in home.text
assert '<meta name="theme-color" content="#0b3d91"' in home.text
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 令牌全集随构建分发
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
def test_trade_pro_product_layout_structure(factory):
# Task 10b:product.html 移植 cms 产品详情设计([slug].astro)后的结构契约。
# 构建流(/preview/ HTTP)+ 带 fields/gallery 的产品条目驱动断言:
# 画廊(主图封面 + data-gallery-image 缩略图,复用平台 pro.js 增强钩子)、
# 标题区、参数表渲染 fields 键值(品牌等管理字段不进表)、询盘 CTA、
# 相关产品复用首页卡片网格;旧骨架 .pro-product-layout/.pro-specs 无残留。
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.',
'meta': {'fields': {'brand': 'Quanta', 'range': '500 km', 'pressure': '210 bar',
'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg']}}})
assert made.status_code in (200, 201), made.text
other = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'product', 'title': 'Companion Valve', 'route': '/product/valve/',
'summary': 'Matching control valve.'})
assert other.status_code in (200, 201), other.text
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
r = factory.get(f'/preview/{sid}/{bid}/product/hp-200/')
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
# 标题区(唯一 h1)+ 询盘 CTA 指向联系页(/preview/ 响应会把根相对
# href 重写为 /preview/{sid}/{bid}/contact/,故用后缀匹配)
assert soup.select_one('.sf-tp-product-summary h1.sf-tp-product-title')
assert soup.select_one('.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]')
# 参数表:fields 键值渲染(下划线转空格 + 首字母大写),空表整段隐藏
spec_table = soup.select_one('table.sf-tp-spec-table')
assert spec_table is not None
spec_text = spec_table.get_text()
assert 'Pressure' in spec_text and '210 bar' in spec_text and '500 km' in spec_text
assert 'Quanta' not in spec_text # brand 等管理字段不进参数表
# 相关产品:复用 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
# 旧骨架清理:无 .pro-product-layout/.pro-specs 残留类依赖
assert 'pro-product-layout' not in html and 'pro-specs' not in html
assert len(soup.find_all('h1')) == 1
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
# 105 个产品:/categories/pumps/ 走 render_paginated(LIST_PAGE_SIZE=100,
# 两页真实静态分页路由),比 101 次 /entries POST 便宜得多
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')
assert len(s1.find_all('h1')) == 1
nav1 = s1.select_one('nav.sf-tp-pagination')
assert nav1 is not None
assert len(s1.select('.sf-tp-card-grid--listing .sf-tp-card')) == 100
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')
assert len(s2.find_all('h1')) == 1
nav2 = s2.select_one('nav.sf-tp-pagination')
assert len(s2.select('.sf-tp-card-grid--listing .sf-tp-card')) == 5
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
assert nav2.select_one('a[rel="next"]') is None
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
assert 'data-pro-filter-search' not in cases
assert 'sf-tp-pagination' not in cases
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')))
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
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
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 # 字段空则行隐藏
def test_trade_pro_standard_layout_structure(factory):
# Task 10h:standard.html 移植 cms 通用内容页设计(applications.astro +
# support.astro,像素基准 dist/hesupipe/en/{applications,support}/)后的
# 结构契约。standard 是所有非专属 kind 的兜底模板,两页共性形态:
# PageHero(眉题/h1/导语/页内锚点按钮 + 封面底图)→ 内容区。
# 与 cms 的结构决策:两页内容区的应用卡网格 / FeatureGrid / 锚点导航 /
# 文档卡 / FAQ 全是 HESU 词典内容而非布局——跨站即假数据,不移植;
# 平台 standard 页内容 = 富文本 body,小节结构由正文自身 h2 承担
# (10g 案例详情同决策),排版层双挂 sf-tp-article-body(10d)。
# body 为空 → 正文区块与 hero 锚点按钮整段隐藏(cms isRichAbout 三元
# 分支同款)。meta.page_template='standard' 优先于路由前缀推断
# (infer_kind 显式分支先于 /services/ 前缀)——模板不绑路由:
# bootstrap 的 /services/ 页(meta standard)渲染本模板而非 service 模板。
import re
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
made = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'page', 'title': 'Applications', 'route': '/applications/',
'cover': 'https://imgs.example/water-treatment.jpg',
'summary': 'Piping systems for infrastructure, energy, industry and agriculture.',
'body': '<p>Our systems serve municipal, agricultural and industrial projects worldwide.</p><h2>Municipal engineering</h2><p>Reliable piping for urban utility networks.</p>'})
assert made.status_code in (200, 201), made.text
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
# ── 主流程:普通页面条目(无 meta.page_template,infer_kind 兜底回落
# standard)→ hero + 正文区块 ──
r = factory.get(f'/preview/{sid}/{bid}/applications/')
assert r.status_code == 200, r.text[:400]
html = r.text
soup = BeautifulSoup(html, 'html.parser')
# hero:面包屑(Home › 标题)+ 封面底图 + 眉题 + 唯一 h1 + 导语
hero = soup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero')
assert hero is not None
bg = hero.select_one('.sf-tp-page-hero-bg')
assert bg is not None and 'water-treatment.jpg' in bg.get('src', '')
assert hero.select_one('.sf-tp-breadcrumb a')
current = hero.select_one('.sf-tp-breadcrumb-current')
assert current is not None and current.get_text(strip=True) == 'Applications'
assert hero.select_one('.sf-tp-page-hero-copy[data-reveal] h1')
assert 'Piping systems for infrastructure' in hero.select_one('.sf-tp-page-hero-lead').get_text()
assert len(soup.find_all('h1')) == 1
# cms PageHero 页内锚点按钮(applications/support 均有):锚到正文区块,
# 箭头为 SVG(Task 9 契约)
btn = hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-standard-body"]')
assert btn is not None and btn.select_one('svg') is not None
# 正文区块:SectionHead(眉题 + 页题 h2——cms about-body 的 SectionHead
# title={heroTitle} 同构)+ 富文本正文(10d 排版层双挂),正文 h2 承担小节
body_section = soup.select_one('section.sf-tp-standard-body-section#tp-standard-body')
assert body_section is not None
head = body_section.select_one('.sf-tp-section-head')
assert head is not None and head.select_one('.eyebrow') and head.find('h2')
body = body_section.select_one('.sf-tp-standard-body.sf-tp-article-body .pro-rich')
assert body is not None and 'Municipal engineering' in body.get_text()
# 旧骨架(Phase 2 pro-info 双栏)无残留
assert 'pro-info-hero' not in html and 'pro-info-section' not in html
# 交互箭头一律 SVG(Task 9 契约)
assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a')))
# 本构建链接全通(面包屑与锚点按钮不产生断链)
report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
# ── 空数据 + 模板不绑路由:bootstrap 的 /services/ 页 meta.page_template
# ='standard'(显式 meta 优先于路由前缀推断),无 body → 正文区块与
# 锚点按钮整段隐藏;渲染 standard 模板而非 service 模板 ──
services = factory.get(f'/preview/{sid}/{bid}/services/')
assert services.status_code == 200, services.text[:300]
ssoup = BeautifulSoup(services.text, 'html.parser')
assert len(ssoup.find_all('h1')) == 1
assert ssoup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero') is not None
assert 'pro-info-hero' not in services.text # service 模板骨架未渲染
assert ssoup.select_one('.sf-tp-standard-body-section') is None # 无 body 整段隐藏
assert '#tp-standard-body' not in services.text # 锚点按钮随正文隐藏
assert ssoup.select_one('.sf-tp-page-hero-bg') is None # 无封面 → 无底图
assert ssoup.select_one('.sf-tp-page-hero-lead') is not None # bootstrap 摘要在导语位
def test_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/
# 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']
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
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("{% from '_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("{% from '_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)
if any(ch in body for ch in '→←↗↘⌕▾'):
offenders.append(str(f.relative_to(root)))
# › 豁免通道收紧为行级:同行的 chevron 语义才放行
for line in body.splitlines():
if '›' in line and 'chevron' not in line:
offenders.append(f'{f.relative_to(root)}:›')
assert offenders == []
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'
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 */'
# 无主题版时回落平台版(精确断言内容一致)
dist2 = tmp_path / 'assets2'; dist2.mkdir()
builder.copy_blocks_css(tmp_path / 'nonexistent', dist2)
platform = (builder.ROOT / 'app' / 'static' / 'blocks.css').read_text(encoding='utf-8')
assert (dist2 / 'blocks.css').read_text(encoding='utf-8') == platform