Files
cms-SiteForge/tests/test_theme_registry.py
T
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

317 lines
18 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_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