Files
cms-SiteForge/tests/test_theme_registry.py
T
浅念 0ec826bd4b feat: port cms case layout into trade-pro
Task 10g: rewrite pages/case.html from the thin 1-line variant to the cms
projects/[slug].astro design (pixel baseline dist/hesupipe/en/projects/):
PageHero (breadcrumb Home > Cases > title, cover bg) + 1.2fr/0.8fr detail
grid (overview block with location/industry rows + rich body via the 10d
typography layer; sticky side column with main photo, 3-col gallery tiles
and Results card) + Recommended Products + Other Projects (groups['case']
minus self), all empty-data sections hidden. Results points come from the
open fields.results convention (semicolon/newline separated); cms
dictionary fallback copy is never ported (fake data across sites).

Listing case branch (10c evaluation): core form (hero + cols-3 cards +
pagination) already matches cms projects list; only the hero copy voice
differed, so listing.html gains a case-aware eyebrow/lead fallback
(OUR PROJECTS). cms-specific tabs (no data source) and the FEATURED CASE
STUDY band (HESU-static dictionary content) stay unported, documented in
the template header.

Adds test_trade_pro_case_layout_structure (build-flow: gallery/results
structure, related-case dedup, empty-data hiding, /cases/ hero voice).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 16:59:52 +08:00

808 lines
50 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_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