2026-10-22 17:14:44 +08:00
|
|
|
"""Theme discovery must be a single source of truth."""
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
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
|
|
|
|
|
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-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
|