"""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 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 '/)后的结构契约。构建流(/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': '
Inspection requirements for HDPE pipe exports.
Bundles are wrapped and strapped.
', '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'))) def test_trade_pro_about_layout_structure(factory): # Task 10e:about.html 移植 cms 关于页设计(about.astro,像素基准 # dist/hesupipe/en/about/)后的结构契约。 # 区块序列:PageHero(面包屑 + 封面底图 + 页内锚点按钮)→ 公司介绍两栏 # (文案 + 实景图 + 悬浮数据条)→ 富文本正文(仅当 body 存在)→ # 优势特性网格 → 深色使命带(实景底图 + 玻璃卡)→ 厂区图集卡(仅当 # gallery 存在)→ 资质特性网格。平台事实:about 数据 = 普通页面条目 # (title/summary/body/cover + fields.gallery),cms 词典中的 stats/ # 厂区文案无对应字段——stats 复用 10a 首页的派生计数(产品/分类/品牌/ # 案例),厂区卡片由 page.cover+fields.gallery 驱动,均空则整段隐藏。 # 双构建断言:数据齐备的 /about/(bootstrap 必备栏目路由)+ 空数据的 # 自定义路由 about 页(meta.page_template 显式指定,不绑定特定路由)。 import re sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 # 数据齐备的 about 条目:PATCH bootstrap 建立的 /about/(保留原路由) entries = factory.get(f'/api/sites/{sid}/entries').json() about_id = next(e['id'] for e in entries if e['route'] == '/about/') cover = 'https://imgs.example/factory-floor.jpg' shot1 = 'https://imgs.example/workshop-line.jpg' shot2 = 'https://imgs.example/finished-goods.jpg' patched = factory.patch(f'/api/sites/{sid}/entries/{about_id}', json={ 'title': 'About Us', 'summary': 'Manufacturer and export partner since day one.', 'body': 'Quality control runs from raw material to loaded container.
Every batch is inspected before dispatch.
', 'cover': cover, 'meta': {'fields': {'gallery': f'{shot1}; {shot2}'}}}) assert patched.status_code == 200, patched.text # 3 个产品(1 品牌 1 分类)+ 1 篇文章:驱动派生 stats 与 /news/ 链接 batch = [{'type': 'product', 'title': f'Valve Series {i}', 'summary': f'Control valve {i}.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Valves'}}} for i in range(3)] batch.append({'type': 'post', 'title': 'Plant Expansion Update', 'summary': 'New extrusion lines.'}) import io payload = json.dumps(batch).encode('utf-8') imported = factory.post(f'/api/sites/{sid}/import', files={'file': ('about-batch.json', io.BytesIO(payload), 'application/json')}) assert imported.status_code == 200, imported.text[:300] 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'] # ── 数据齐备 /about/:hero + 介绍两栏 + 正文 + 四个设计区块 ── html = factory.get(f'/preview/{sid}/{bid}/about/').text soup = BeautifulSoup(html, 'html.parser') assert len(soup.find_all('h1')) == 1 hero = soup.select_one('section.sf-tp-page-hero.sf-tp-about-hero') assert hero is not None bg = hero.select_one('.sf-tp-page-hero-bg') assert bg is not None and 'factory-floor.jpg' in bg.get('src', '') # 面包屑:Home + 当前页标题(非链接) assert hero.select_one('.sf-tp-breadcrumb a') current = hero.select_one('.sf-tp-breadcrumb-current') assert current is not None and current.get_text(strip=True) == 'About Us' # hero 锚点按钮(cms PageHero button → #main 的页内对应物) assert hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-about-intro"]') assert 'Manufacturer and export partner' in hero.select_one('.sf-tp-page-hero-lead').get_text() # 介绍两栏:文案列(摘要 + 有文章时的 /news/ 链接)+ 实景图 + 派生数据条 intro = soup.select_one('section.sf-tp-about-intro .sf-tp-two-col') assert intro is not None assert intro.select_one('.sf-tp-copy h2') assert 'Manufacturer and export partner' in intro.select_one('.sf-tp-copy p').get_text() assert intro.select_one('.sf-tp-copy a.sf-tp-btn[href$="/news/"]') photo = intro.select_one('.sf-tp-photo img, .sf-tp-photo .sf-tp-photo-plain') assert photo is not None stats = intro.select('.sf-tp-stats .sf-tp-stat') assert len(stats) >= 2 and 'Products' in intro.select_one('.sf-tp-stats').get_text() # 富文本正文:body 存在才渲染(cms isRichAbout 分支的平台化) body = soup.select_one('.sf-tp-about-body .pro-rich') assert body is not None and 'Quality control' in body.get_text() and 'Our standards' in body.get_text() # 优势 / 使命 / 资质:静态设计内容(cms 词典区块),特性卡带 SVG 图标 assert len(soup.select('.sf-tp-about-advantages .sf-tp-feature')) == 4 assert all(f.select_one('.icon-circle svg') for f in soup.select('.sf-tp-about-advantages .sf-tp-feature')) mission = soup.select_one('section.section--dark.sf-tp-about-mission') assert mission is not None assert len(mission.select('.sf-tp-feature')) == 4 assert mission.select_one('.sf-tp-intro') assert len(soup.select('.sf-tp-about-certs .sf-tp-feature')) == 4 # 厂区图集卡:cover + fields.gallery 去重共 3 图 facilities = soup.select_one('.sf-tp-about-facilities') assert facilities is not None cards = facilities.select('.sf-tp-about-facility') assert len(cards) == 3 srcs = ' '.join(i.get('src', '') for i in facilities.select('img')) assert 'workshop-line.jpg' in srcs and 'finished-goods.jpg' in srcs # 交互箭头一律 SVG(Task 9 契约) assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))) # ── 空数据 about(自定义路由 + meta.page_template 显式指定)── # 无 cover/body/gallery/产品:hero 无底图、正文与图集整段隐藏、stats # 整段隐藏、/news/ 链接不渲染;静态设计区块保留(完整页面而非两栏摘要)。 sid2 = site(factory) made = factory.post(f'/api/sites/{sid2}/entries', json={ 'type': 'page', 'title': 'Our Story', 'route': '/our-story/', 'meta': {'page_template': 'about'}}) assert made.status_code in (200, 201), made.text assert factory.put(f'/api/factory/sites/{sid2}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200 bid2 = factory.post(f'/api/sites/{sid2}/builds', json={}).json()['version'] bare = factory.get(f'/preview/{sid2}/{bid2}/our-story/') assert bare.status_code == 200, bare.text[:300] bsoup = BeautifulSoup(bare.text, 'html.parser') assert len(bsoup.find_all('h1')) == 1 assert bsoup.select_one('section.sf-tp-about-hero') is not None assert bsoup.select_one('.sf-tp-page-hero-bg') is None assert bsoup.select_one('.sf-tp-about-body') is None assert bsoup.select_one('.sf-tp-about-facilities') is None assert bsoup.select_one('.sf-tp-stats') is None assert bsoup.select_one('.sf-tp-about-intro .sf-tp-copy a.sf-tp-btn[href$="/news/"]') is None assert bsoup.select_one('.sf-tp-copy p').get_text(strip=True) # 英文兜底文案 assert len(bsoup.select('.sf-tp-about-advantages .sf-tp-feature')) == 4 assert bsoup.select_one('section.section--dark.sf-tp-about-mission') is not None assert len(bsoup.select('.sf-tp-about-certs .sf-tp-feature')) == 4 def test_trade_pro_contact_layout_structure(factory): # Task 10f:contact.html 移植 cms 联系页设计(contact.astro + LeadForm.astro, # 像素基准 dist/hesupipe/en/contact/)后的结构契约。 # 区块序列:PageHero(面包屑 + 封面底图 + 眉题/h1/导语)→ 联系信息/ # 表单双栏(0.85fr 信息列 + 1.25fr 灰底表单卡)→ 富文本正文(body # 存在才渲染)。cms 的地图带(Google 嵌入 = 公司真实地址数据)不移植 # ——平台无地址字段,虚构地址即假数据;结尾 Cta 由 pro.html 全局 # pro-end-cta 覆盖。 # 无假提交双形态:配置 form_endpoint(HTTPS 双层校验——API 400 拒绝 # 非 HTTPS + make_context 渲染层回退)→ 外部表单(name/email/message # + pro-honeypot 蜜罐,10b 同款契约,隐藏规则随构建分发);未配置 → # 不渲染任何