Files
cms-SiteForge/tests/test_theme_registry.py
T

990 lines
62 KiB
Python
Raw Normal View History

"""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')))
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
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 # 字段空则行隐藏
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_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