Files
cms-SiteForge/tests/test_theme_registry.py
T
浅念 c318829ff3 feat: port cms home layout into trade-pro (hero/features/cards/cta + reveal JS)
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>
2026-10-23 09:23:30 +08:00

264 lines
14 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
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