c318829ff3
Task 10a (Phase 3 first layout task). home.html now mirrors the cms astro-site-runtime homepage section sequence: full-bleed hero (single active slide; the platform has no multi-slide config, so the carousel dots/JS are not ported), trustbar, two-col about with floating stats, soft products band (4-card grid), dark applications band (2x2 case tiles), advantages feature grid, soft news band (3-card grid), and the closing cta band. Data maps to SiteForge context (page.cover/summary, showcase, cases, stories, category/brand counts); empty sections hide instead of rendering shells. All new component classes are sf-tp-* prefixed and compose on the Task 8 base utilities in theme.css (.wrap/.section/.eyebrow/.icon-circle/.sf-tp-btn). CSS: the five cms components' scoped styles (PageHero, FeatureGrid, CardGrid, Cta, SectionHead + the inline homepage bands) are extracted into pro.css as the sf-tp-* component layer, tokens rewritten to --sf-tp-*, breakpoints 900/767/620 preserved. theme.css accordingly drops the superseded generic .cards/.card/.feature mobile baselines and the .pro-home-hero em bridge, and retargets the dark-surface ghost button hook from .cta-band to .sf-tp-cta. Reveal: cms uses data-reveal everywhere; a ~15-line inline script at the pro.html tail adds html.js + an IntersectionObserver that toggles is-visible (the reduced-motion/no-JS exemptions already live in theme.css from Task 8). Tests: test_theme_registry gains test_trade_pro_home_layout_structure (preview-render contract: sf-tp-hero, card grid, feature grid, cta, no pro-industrial-hero residue, reveal script). The distinct-markup tripwire in test_theme_pro_v3 is legitimately updated 4 -> 5 (renamed to drop the stale "four"): with trade-pro now carrying its own sf-tp-hero class, five themes yield five distinct hero signatures, which is what the test always meant to enforce. Co-Authored-By: Claude Code <noreply@anthropic.com>
264 lines
14 KiB
Python
264 lines
14 KiB
Python
"""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
|
||
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_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
|