2026-10-22 17:14:44 +08:00
|
|
|
|
"""Theme discovery must be a single source of truth."""
|
|
|
|
|
|
import json
|
|
|
|
|
|
from pathlib import Path
|
2026-10-23 09:23:30 +08:00
|
|
|
|
from bs4 import BeautifulSoup
|
2026-10-22 17:14:44 +08:00
|
|
|
|
from app import core, theme_pro, theme_store
|
2026-10-22 18:19:30 +08:00
|
|
|
|
from test_factory_v2 import factory, site # noqa: F401 -- fixture + helper reused by trade-pro tests
|
2026-10-22 17:14:44 +08:00
|
|
|
|
|
|
|
|
|
|
ROOT = Path(core.__file__).resolve().parent.parent
|
|
|
|
|
|
|
|
|
|
|
|
def test_discover_returns_builtin_themes_with_manifests():
|
|
|
|
|
|
ids = core.discover_themes()
|
|
|
|
|
|
for tid in ('automotive', 'industrial', 'corporate', 'minimal'):
|
|
|
|
|
|
assert tid in ids
|
|
|
|
|
|
for tid in ids:
|
|
|
|
|
|
manifest = json.loads((ROOT / 'themes' / tid / 'manifest.json').read_text(encoding='utf-8'))
|
|
|
|
|
|
assert manifest.get('id') == tid
|
|
|
|
|
|
|
|
|
|
|
|
def test_three_whitelists_agree():
|
2026-10-22 17:28:57 +08:00
|
|
|
|
# Tripwire: the three names must stay the same object. Fails only if someone
|
|
|
|
|
|
# reintroduces a hardcoded copy of the list anywhere.
|
2026-10-22 17:14:44 +08:00
|
|
|
|
assert set(core.THEMES) == set(theme_pro.THEMES) == set(theme_store.BUILTIN)
|
|
|
|
|
|
assert isinstance(core.THEMES, tuple)
|
2026-10-22 17:28:57 +08:00
|
|
|
|
|
|
|
|
|
|
def test_discover_skips_broken_theme_dirs(tmp_path, monkeypatch):
|
|
|
|
|
|
themes = tmp_path / 'themes'
|
|
|
|
|
|
good = themes / 'good-one'; good.mkdir(parents=True)
|
|
|
|
|
|
(good / 'manifest.json').write_text(json.dumps({'id': 'good-one'}), encoding='utf-8')
|
|
|
|
|
|
broken = themes / 'broken'; broken.mkdir()
|
|
|
|
|
|
(broken / 'manifest.json').write_text('{not json', encoding='utf-8')
|
|
|
|
|
|
mismatch = themes / 'mismatch'; mismatch.mkdir()
|
|
|
|
|
|
(mismatch / 'manifest.json').write_text(json.dumps({'id': 'other-name'}), encoding='utf-8')
|
|
|
|
|
|
no_manifest = themes / 'no-manifest'; no_manifest.mkdir()
|
|
|
|
|
|
underscore = themes / '_shared'; underscore.mkdir()
|
|
|
|
|
|
monkeypatch.setattr(core, 'ROOT', tmp_path)
|
|
|
|
|
|
assert core.discover_themes() == ('good-one',)
|
|
|
|
|
|
|
|
|
|
|
|
def test_discover_fails_fast_when_themes_dir_missing(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setattr(core, 'ROOT', tmp_path)
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
with pytest.raises(RuntimeError, match='themes directory missing'):
|
|
|
|
|
|
core.discover_themes()
|
2026-10-22 17:33:05 +08:00
|
|
|
|
|
|
|
|
|
|
def test_env_for_theme_prefers_theme_dir_then_shared_root():
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
env = theme_pro.env_for('industrial')
|
|
|
|
|
|
paths = [str(p).replace('\\', '/') for p in env.loader.searchpath]
|
|
|
|
|
|
assert paths[0].endswith('themes/industrial')
|
|
|
|
|
|
assert paths[1].endswith('themes')
|
|
|
|
|
|
# 共享宏库必须仍可解析(回落路径)
|
|
|
|
|
|
assert env.get_template('_professional_components.html') is not None
|
|
|
|
|
|
|
|
|
|
|
|
def test_env_for_is_cached_per_theme():
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
assert theme_pro.env_for('industrial') is theme_pro.env_for('industrial')
|
|
|
|
|
|
|
|
|
|
|
|
def test_env_for_pro_template_loads_unprefixed():
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
assert theme_pro.env_for('industrial').get_template('pro.html') is not None
|
2026-10-22 17:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
def test_env_for_rejects_unknown_theme():
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
|
theme_pro.env_for('../../etc')
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
|
theme_pro.env_for('no-such-theme')
|
|
|
|
|
|
|
|
|
|
|
|
def test_env_for_theme_macro_shadows_shared_root(tmp_path, monkeypatch):
|
|
|
|
|
|
# The core mechanism: a theme-local _professional_components.html wins over
|
|
|
|
|
|
# the shared one at themes/ root.
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
themes = tmp_path / 'themes'
|
|
|
|
|
|
for sub, marker in (('industrial', 'theme-local'), ('_root_macros', None)):
|
|
|
|
|
|
d = themes / sub
|
|
|
|
|
|
d.mkdir(parents=True)
|
|
|
|
|
|
(themes / '_professional_components.html').write_text('SHARED', encoding='utf-8')
|
|
|
|
|
|
(themes / 'industrial' / '_professional_components.html').write_text('THEME-LOCAL', encoding='utf-8')
|
|
|
|
|
|
monkeypatch.setattr(theme_pro, 'ROOT', tmp_path)
|
|
|
|
|
|
monkeypatch.setattr(theme_pro, '_ENVS', {})
|
|
|
|
|
|
tpl = theme_pro.env_for('industrial').get_template('_professional_components.html')
|
|
|
|
|
|
assert tpl.render().strip() == 'THEME-LOCAL'
|
|
|
|
|
|
|
|
|
|
|
|
def test_env_for_degraded_branch_not_cached_and_single_path(tmp_path, monkeypatch):
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
themes = tmp_path / 'themes'
|
|
|
|
|
|
themes.mkdir(parents=True)
|
|
|
|
|
|
monkeypatch.setattr(theme_pro, 'ROOT', tmp_path)
|
|
|
|
|
|
monkeypatch.setattr(theme_pro, '_ENVS', {})
|
|
|
|
|
|
core.THEMES = ('industrial',) # whitelist a theme whose dir does not exist
|
|
|
|
|
|
try:
|
|
|
|
|
|
env = theme_pro.env_for('industrial')
|
|
|
|
|
|
assert [str(p) for p in env.loader.searchpath] == [str(themes)]
|
|
|
|
|
|
assert 'industrial' not in theme_pro._ENVS
|
|
|
|
|
|
finally:
|
|
|
|
|
|
core.THEMES = core.discover_themes()
|
2026-10-22 17:49:12 +08:00
|
|
|
|
|
2026-10-22 18:19:30 +08:00
|
|
|
|
def test_trade_pro_registered_and_compatible():
|
|
|
|
|
|
assert 'trade-pro' in core.THEMES
|
|
|
|
|
|
result = theme_store.compatibility('trade-pro')
|
|
|
|
|
|
assert result['valid'], result['errors']
|
|
|
|
|
|
assert not result['errors']
|
|
|
|
|
|
|
|
|
|
|
|
def test_trade_pro_builds_real_site(factory):
|
|
|
|
|
|
sid = site(factory)
|
|
|
|
|
|
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
|
|
|
|
|
selected = factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'})
|
|
|
|
|
|
assert selected.status_code == 200, selected.text
|
2026-10-23 12:31:03 +08:00
|
|
|
|
# V8.15+ multilingual rewrites <html lang> per site locale — declare English
|
|
|
|
|
|
loc = factory.put(f'/api/i18n/sites/{sid}/locale', json={'locale': 'en'})
|
|
|
|
|
|
assert loc.status_code == 200, loc.text
|
2026-10-22 18:19:30 +08:00
|
|
|
|
result = factory.post(f'/api/sites/{sid}/builds', json={})
|
|
|
|
|
|
assert result.status_code == 200, result.text
|
|
|
|
|
|
bid = result.json()['version']
|
|
|
|
|
|
home = factory.get(f'/preview/{sid}/{bid}/')
|
|
|
|
|
|
assert home.status_code == 200, home.text[:400]
|
|
|
|
|
|
assert 'lang="en"' in home.text
|
2026-10-22 18:40:49 +08:00
|
|
|
|
assert '>Products<' in home.text # English fallback nav actually rendered
|
2026-10-22 18:19:30 +08:00
|
|
|
|
assert '>首页<' not in home.text
|
|
|
|
|
|
assert '<meta name="theme-color" content="#0b3d91"' in home.text
|
|
|
|
|
|
|
2026-10-23 08:07:34 +08:00
|
|
|
|
def test_trade_pro_theme_css_ports_token_system():
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
css = (Path(__file__).resolve().parent.parent / 'themes' / 'trade-pro' / 'theme.css').read_text(encoding='utf-8')
|
|
|
|
|
|
# 令牌覆盖:cms tokens.css 的核心令牌语义都应有对应 --sf-tp-* 变量
|
|
|
|
|
|
for token in ('primary', 'accent', 'bg', 'text', 'muted', 'radius', 'font'):
|
|
|
|
|
|
assert f'--sf-tp-{token}' in css, token
|
|
|
|
|
|
# 移植覆盖:cms 语义令牌以 --sf-tp- 前缀保留语义名(tokens.css 各组取样)
|
|
|
|
|
|
for token in ('color-brand-primary', 'color-brand-secondary', 'color-neutral-900', 'color-text-primary',
|
|
|
|
|
|
'color-surface-page', 'color-border-default', 'font-family-sans', 'font-size-display',
|
|
|
|
|
|
'font-weight-bold', 'line-height-normal', 'space-4', 'space-section-y', 'container-page-max',
|
|
|
|
|
|
'breakpoint-tablet', 'radius-full', 'shadow-md', 'button-height-md', 'input-border-radius',
|
|
|
|
|
|
'card-padding-md', 'duration-fast', 'easing-standard', 'z-modal', 'focus-outline-color', 'icon-size-md'):
|
|
|
|
|
|
assert f'--sf-tp-{token}' in css, token
|
|
|
|
|
|
# 基础样式:base.css 元素基线、按钮体系与响应式断点已移植
|
|
|
|
|
|
for fragment in ('box-sizing', '.sf-tp-btn--lg', '.sf-tp-btn--ghost', '@media (max-width: 620px)',
|
|
|
|
|
|
'prefers-reduced-motion'):
|
|
|
|
|
|
assert fragment in css, fragment
|
|
|
|
|
|
# 样式隔离红线:不得出现平台按钮选择器
|
|
|
|
|
|
import re
|
|
|
|
|
|
for sel in ('.btn{', '.primary{', '.request{', '.button{', '.nav-btn{'):
|
|
|
|
|
|
assert sel not in css, sel
|
|
|
|
|
|
|
|
|
|
|
|
def test_trade_pro_build_contains_tokens(factory):
|
|
|
|
|
|
# 构建产物 assets/theme.css 含 --sf-tp- 变量(复用 test_trade_pro_builds_real_site 的构建流程)
|
|
|
|
|
|
sid = site(factory)
|
|
|
|
|
|
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
|
|
|
|
|
selected = factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'})
|
|
|
|
|
|
assert selected.status_code == 200, selected.text
|
|
|
|
|
|
result = factory.post(f'/api/sites/{sid}/builds', json={})
|
|
|
|
|
|
assert result.status_code == 200, result.text
|
|
|
|
|
|
bid = result.json()['version']
|
|
|
|
|
|
home = factory.get(f'/preview/{sid}/{bid}/')
|
|
|
|
|
|
assert home.status_code == 200, home.text[:400]
|
|
|
|
|
|
assert 'assets/theme.css' in home.text # 首页 HTML 确实链接主题样式表
|
|
|
|
|
|
css = factory.get(f'/preview/{sid}/{bid}/assets/theme.css')
|
|
|
|
|
|
assert css.status_code == 200, css.text[:200]
|
|
|
|
|
|
assert '--sf-tp-primary' in css.text # Phase 2 短令牌别名随构建分发
|
|
|
|
|
|
assert '--sf-tp-color-brand-primary' in css.text # cms 令牌全集随构建分发
|
|
|
|
|
|
|
2026-10-23 09:23:30 +08:00
|
|
|
|
def test_trade_pro_home_layout_structure(factory):
|
|
|
|
|
|
# Task 10a:home.html 移植 cms 首页设计后的结构契约(sf-tp-* 组件类,
|
|
|
|
|
|
# 数据区块由 preview 的 3 个演示产品驱动;无 pro-industrial-hero 残留)。
|
|
|
|
|
|
r = factory.get('/api/factory/themes/trade-pro/preview', params={'page_type': 'home'})
|
|
|
|
|
|
assert r.status_code == 200, r.text[:400]
|
|
|
|
|
|
soup = BeautifulSoup(r.text, 'html.parser')
|
|
|
|
|
|
assert soup.select_one('section.sf-tp-hero') is not None
|
|
|
|
|
|
assert 'pro-industrial-hero' not in r.text
|
|
|
|
|
|
assert soup.select_one('.sf-tp-hero .sf-tp-hero-copy[data-reveal] h1') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-hero .sf-tp-hero-overlay') is not None
|
|
|
|
|
|
assert len(soup.select('.sf-tp-trustbar .sf-tp-trust-item')) == 4
|
|
|
|
|
|
assert soup.select_one('.sf-tp-trust-item .icon-circle svg') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-two-col .sf-tp-copy .sf-tp-btn[href="/about/"]') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-section-head') is not None
|
|
|
|
|
|
cards = soup.select('.sf-tp-card-grid .sf-tp-card')
|
|
|
|
|
|
assert len(cards) == 3 # preview 提供 3 个演示产品
|
|
|
|
|
|
assert cards[0].select_one('.sf-tp-card-body .sf-tp-more[href]') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-feature-grid .sf-tp-feature .icon-circle svg') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-cta .sf-tp-btn--ghost[href="/contact/"]') is not None
|
|
|
|
|
|
# reveal 渐进增强(pro.html 尾部内联 JS):html.js 开关 + IntersectionObserver
|
|
|
|
|
|
assert "classList.add('js')" in r.text and 'IntersectionObserver' in r.text
|
|
|
|
|
|
assert len(soup.find_all('h1')) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-23 10:17:31 +08:00
|
|
|
|
def test_trade_pro_product_layout_structure(factory):
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-23 14:06:15 +08:00
|
|
|
|
def test_trade_pro_listing_layout_structure(factory):
|
|
|
|
|
|
# Task 10c:listing.html(category.html 同构复用)移植 cms 列表设计后的结构契约。
|
|
|
|
|
|
# 设计源 D:/project/cms/runtimes/astro-site-runtime/src/pages/[locale]/
|
|
|
|
|
|
# products/[...page].astro(260px 分类侧栏 + 3 列卡片栅格 + 窗口式分页)
|
|
|
|
|
|
# news/[...page].astro(310px 侧栏 + 横向新闻行列表 + 侧栏盒组)
|
|
|
|
|
|
# 平台契约保留:[data-pro-filter-search] 筛选、.pro-filter-panel 必须是
|
|
|
|
|
|
# .pro-filter-scope 的紧邻前兄弟(pro.js previousElementSibling 定位)、
|
|
|
|
|
|
# 卡片/行携带 .pro-card + data-brand/data-category(pro.js 过滤数据源)。
|
|
|
|
|
|
# 分页器:cms 窗口式页码(首尾 + 当前±2),上一页/下一页为 SVG 箭头。
|
|
|
|
|
|
import io
|
|
|
|
|
|
import re
|
|
|
|
|
|
sid = site(factory)
|
|
|
|
|
|
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
|
|
|
|
|
# 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')
|
2026-10-23 14:26:34 +08:00
|
|
|
|
assert len(s1.find_all('h1')) == 1
|
2026-10-23 14:06:15 +08:00
|
|
|
|
nav1 = s1.select_one('nav.sf-tp-pagination')
|
|
|
|
|
|
assert nav1 is not None
|
|
|
|
|
|
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')
|
2026-10-23 14:26:34 +08:00
|
|
|
|
assert len(s2.find_all('h1')) == 1
|
2026-10-23 14:06:15 +08:00
|
|
|
|
nav2 = s2.select_one('nav.sf-tp-pagination')
|
|
|
|
|
|
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
|
2026-10-23 14:26:34 +08:00
|
|
|
|
assert 'data-pro-filter-search' not in cases
|
|
|
|
|
|
assert 'sf-tp-pagination' not in cases
|
2026-10-23 14:06:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-23 15:04:07 +08:00
|
|
|
|
def test_trade_pro_article_layout_structure(factory):
|
|
|
|
|
|
# Task 10d:article.html 移植 cms 文章详情设计(news/[slug].astro,像素基准
|
|
|
|
|
|
# dist/hesupipe/en/news/<slug>/)后的结构契约。构建流(/preview/ HTTP)+
|
|
|
|
|
|
# 带 cover/publish_date/富文本正文的文章条目 + 4 条其他文章驱动断言:
|
|
|
|
|
|
# hero(.pro-article-hero 平台契约保留 + 封面底图 + 面包屑)、meta 行
|
|
|
|
|
|
# (分类胶囊 · 发布日期 · 作者)、富文本正文容器、作者卡、下一篇导航、
|
|
|
|
|
|
# More News 相关盒(不含自身、不含下一篇的去重列表)+ 侧栏 CTA + 返回;
|
|
|
|
|
|
# 交互箭头一律 SVG,无裸箭头字符。
|
|
|
|
|
|
import re
|
|
|
|
|
|
sid = site(factory)
|
|
|
|
|
|
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
|
|
|
|
|
made = factory.post(f'/api/sites/{sid}/entries', json={
|
|
|
|
|
|
'type': 'post', 'title': 'HDPE Pipe Export Standards Update', 'route': '/news/hdpe-standards/',
|
|
|
|
|
|
'cover': 'https://imgs.example/hdpe-cover.jpg', 'summary': 'Updated inspection rules for HDPE pipe exports.',
|
|
|
|
|
|
'body': '<p>Inspection requirements for <strong>HDPE</strong> pipe exports.</p><h2>Packaging</h2><p>Bundles are wrapped and strapped.</p>',
|
|
|
|
|
|
'meta': {'fields': {'category': 'Company News', 'publish_date': '2026-10-18', 'author': 'Trade Desk'}}})
|
|
|
|
|
|
assert made.status_code in (200, 201), made.text
|
|
|
|
|
|
for i in range(4):
|
|
|
|
|
|
r = factory.post(f'/api/sites/{sid}/entries', json={
|
|
|
|
|
|
'type': 'post', 'title': f'Worksite Report {i}', 'route': f'/news/worksite-{i}/',
|
|
|
|
|
|
'summary': f'Installation report from worksite {i}.'})
|
|
|
|
|
|
assert r.status_code in (200, 201), r.text
|
|
|
|
|
|
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
|
|
|
|
|
|
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
|
|
|
|
|
|
r = factory.get(f'/preview/{sid}/{bid}/news/hdpe-standards/')
|
|
|
|
|
|
assert r.status_code == 200, r.text[:400]
|
|
|
|
|
|
html = r.text
|
|
|
|
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
|
|
|
|
# hero:.pro-article-hero 契约保留(test_theme_pro_v3 对所有主题断言)+
|
|
|
|
|
|
# 封面作 hero 底图 + 面包屑(Home › News › 标题)+ 唯一 h1 + reveal
|
|
|
|
|
|
hero = soup.select_one('section.sf-tp-article-hero.pro-article-hero')
|
|
|
|
|
|
assert hero is not None
|
|
|
|
|
|
bg = hero.select_one('.sf-tp-page-hero-bg')
|
|
|
|
|
|
assert bg is not None and 'hdpe-cover.jpg' in bg.get('src', '')
|
|
|
|
|
|
assert hero.select_one('.sf-tp-breadcrumb a[href$="/news/"]')
|
|
|
|
|
|
assert hero.select_one('.sf-tp-page-hero-copy[data-reveal] h1')
|
|
|
|
|
|
assert len(soup.find_all('h1')) == 1
|
|
|
|
|
|
# meta 行:分类胶囊 + 发布日期(time)+ 作者字段
|
|
|
|
|
|
meta = soup.select_one('.sf-tp-article-meta')
|
|
|
|
|
|
assert meta is not None and 'Company News' in meta.get_text()
|
|
|
|
|
|
assert meta.find('time') is not None and '2026-10-18' in meta.find('time').get_text()
|
|
|
|
|
|
assert 'Trade Desk' in meta.get_text()
|
|
|
|
|
|
# 正文:body_copy 富文本容器(加粗与小节标题随文渲染)
|
|
|
|
|
|
body = soup.select_one('.sf-tp-article-body .pro-rich')
|
|
|
|
|
|
assert body is not None and 'HDPE' in body.get_text() and 'Packaging' in body.get_text()
|
|
|
|
|
|
# 分隔线 + 作者卡(首字母头像)
|
|
|
|
|
|
assert soup.select_one('.sf-tp-article-divider') is not None
|
|
|
|
|
|
assert soup.select_one('.sf-tp-article-author .sf-tp-article-avatar') is not None
|
|
|
|
|
|
# 下一篇导航:排除自身后的首条其他文章
|
|
|
|
|
|
nxt = soup.select_one('a.sf-tp-next-story')
|
|
|
|
|
|
assert nxt is not None and 'Worksite Report' in nxt.get_text()
|
|
|
|
|
|
# 相关阅读:More News 盒 3 条(其余文章),不含自身、不与下一篇重复
|
|
|
|
|
|
items = soup.select('.sf-tp-article-side-box a.sf-tp-article-side-item')
|
|
|
|
|
|
assert len(items) == 3
|
|
|
|
|
|
texts = [i.get_text() for i in items]
|
|
|
|
|
|
assert all('HDPE Pipe Export Standards' not in t for t in texts)
|
|
|
|
|
|
assert nxt.get('href') not in [i.get('href') for i in items]
|
|
|
|
|
|
assert items[0].select_one('.sf-tp-card-media img, .sf-tp-card-media .pro-art')
|
|
|
|
|
|
# 侧栏 CTA 指向联系页;返回链接回 /news/
|
|
|
|
|
|
assert soup.select_one('.sf-tp-article-side-cta a.sf-tp-btn[href$="/contact/"]')
|
|
|
|
|
|
assert soup.select_one('a.sf-tp-article-back[href$="/news/"]')
|
|
|
|
|
|
# 交互箭头一律 SVG(Task 9 契约),链接文本无裸箭头字符
|
|
|
|
|
|
assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a')))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 18:51:27 +08:00
|
|
|
|
def test_frontend_entries_include_trade_pro():
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
base = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
for rel, needle in [
|
|
|
|
|
|
('app/templates/admin.html', 'value="trade-pro"'),
|
|
|
|
|
|
('app/templates/workspace.html', 'value="trade-pro"'),
|
|
|
|
|
|
('app/templates/theme-gallery.html', 'value="trade-pro"'),
|
|
|
|
|
|
('app/static/workspace.js', "'trade-pro'"),
|
|
|
|
|
|
('app/static/admin.js', "'trade-pro'"),
|
|
|
|
|
|
('app/static/factory-v2.js', "'trade-pro'"),
|
|
|
|
|
|
]:
|
|
|
|
|
|
assert needle in (base / rel).read_text(encoding='utf-8'), rel
|
|
|
|
|
|
|
|
|
|
|
|
def test_theme_color_map_covers_trade_pro():
|
|
|
|
|
|
# theme_pro 渲染上下文经映射表输出 trade-pro 的 theme-color(pro.html 已改回变量)
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
src = (Path(theme_pro.__file__).resolve().parent.parent / 'themes' / 'trade-pro' / 'pro.html').read_text(encoding='utf-8')
|
|
|
|
|
|
assert '{{theme_color}}' in src # 不再写死
|
|
|
|
|
|
# 运行时断言:轻量直渲(不经 fixture 构建),映射表 + 变量路径输出 #0b3d91。
|
|
|
|
|
|
# 全量构建路径由 test_trade_pro_builds_real_site 的既有 meta 断言持续覆盖。
|
|
|
|
|
|
html = theme_pro.render('trade-pro', {'theme': 'trade-pro', 'name': 'Go Global'},
|
|
|
|
|
|
{'title': 'Home', 'route': '/'}, [], mode='home')
|
|
|
|
|
|
assert '<meta name="theme-color" content="#0b3d91"' in html
|
|
|
|
|
|
|
2026-10-23 08:35:52 +08:00
|
|
|
|
def test_trade_pro_icon_macro_library():
|
|
|
|
|
|
# Task 9:_professional_components.html 提供 icon(name, size, cls) 内联 SVG 宏。
|
|
|
|
|
|
# 纯服务端输出:无 JS 依赖,装饰性图标 aria-hidden + focusable=false,
|
|
|
|
|
|
# stroke=currentColor 随文字颜色走。
|
|
|
|
|
|
from app import theme_pro
|
|
|
|
|
|
env = theme_pro.env_for('trade-pro')
|
|
|
|
|
|
tpl = env.from_string("{% 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)
|
2026-10-23 09:00:50 +08:00
|
|
|
|
if any(ch in body for ch in '→←↗↘⌕▾'):
|
2026-10-23 08:35:52 +08:00
|
|
|
|
offenders.append(str(f.relative_to(root)))
|
2026-10-23 09:00:50 +08:00
|
|
|
|
# › 豁免通道收紧为行级:同行的 chevron 语义才放行
|
|
|
|
|
|
for line in body.splitlines():
|
|
|
|
|
|
if '›' in line and 'chevron' not in line:
|
|
|
|
|
|
offenders.append(f'{f.relative_to(root)}:›')
|
2026-10-23 08:35:52 +08:00
|
|
|
|
assert offenders == []
|
|
|
|
|
|
|
2026-10-23 09:00:50 +08:00
|
|
|
|
def test_site_html_arrow_matches_macro_library():
|
|
|
|
|
|
# 双源防护:site.html 的自包含 tp_arrow 必须与宏库 arrow-right 的 path 一致
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import re
|
|
|
|
|
|
root = Path(__file__).resolve().parent.parent / 'themes' / 'trade-pro'
|
|
|
|
|
|
macro = (root / '_professional_components.html').read_text(encoding='utf-8')
|
|
|
|
|
|
site = (root / 'site.html').read_text(encoding='utf-8')
|
|
|
|
|
|
m = re.search(r"'arrow-right'\s*:\s*'([^']+)'", macro)
|
|
|
|
|
|
s = re.search(r"tp_arrow.*?d=\"([^\"]+)\"", site, flags=re.S)
|
|
|
|
|
|
assert m and s, 'icon path sources not found'
|
|
|
|
|
|
assert m.group(1) == s.group(1), 'site.html tp_arrow drifted from macro library arrow-right'
|
|
|
|
|
|
|
2026-10-22 17:49:12 +08:00
|
|
|
|
def test_theme_blocks_css_overrides_platform(tmp_path):
|
|
|
|
|
|
from app import builder
|
|
|
|
|
|
dist = tmp_path / 'assets'; dist.mkdir()
|
|
|
|
|
|
theme_dir = tmp_path / 'theme'; theme_dir.mkdir()
|
|
|
|
|
|
(theme_dir / 'blocks.css').write_text('/* theme version */', encoding='utf-8')
|
|
|
|
|
|
builder.copy_blocks_css(theme_dir, dist)
|
|
|
|
|
|
assert (dist / 'blocks.css').read_text(encoding='utf-8') == '/* theme version */'
|
2026-10-22 17:58:05 +08:00
|
|
|
|
# 无主题版时回落平台版(精确断言内容一致)
|
2026-10-22 17:49:12 +08:00
|
|
|
|
dist2 = tmp_path / 'assets2'; dist2.mkdir()
|
|
|
|
|
|
builder.copy_blocks_css(tmp_path / 'nonexistent', dist2)
|
2026-10-22 17:58:05 +08:00
|
|
|
|
platform = (builder.ROOT / 'app' / 'static' / 'blocks.css').read_text(encoding='utf-8')
|
|
|
|
|
|
assert (dist2 / 'blocks.css').read_text(encoding='utf-8') == platform
|