"""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 'HP-200 body copy with export-grade tolerances.

', 'meta': {'fields': {'brand': 'Quanta', 'range': '500 km', 'pressure': '210 bar', 'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg'], 'sections': json.dumps([ {'title': 'Packing & Delivery', 'content': '

Export grade packing on fumigated pallets.

'}, {'title': 'FAQ', 'content': '

Lead time is 20 days after deposit.

'}]), 'features': json.dumps([ {'title': 'High pressure', 'desc': '210 bar continuous rating.'}, {'title': 'Export packing', 'desc': 'Fumigated pallets and desiccant.'}])}}}) 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.', 'meta': {'fields': {'material': 'Stainless steel'}}}) assert other.status_code in (200, 201), other.text # 旧同步产物形态:区块以 H2 段追加在 body 尾部(无 fields.sections) legacy = factory.post(f'/api/sites/{sid}/entries', json={ 'type': 'product', 'title': 'Drainage Pipe DN200', 'route': '/product/dn200/', 'summary': 'Corrugated drainage pipe.', 'body': '

DN200 intro paragraph stays in overview.

' '

Packing & Delivery

Bundled in 6 m lengths.

' '

Our Exhibition

Canton Fair booth photos.

'}) assert legacy.status_code in (200, 201), legacy.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 # 标题区:产品名用 cms h2 量级(2xl 非巨型;平台全站单 h1 契约保留 h1)+ 询盘 CTA # (/preview/ 响应会把根相对 href 重写为 /preview/{sid}/{bid}/contact/,故用后缀匹配) assert soup.select_one('.sf-tp-product-summary h1.sf-tp-product-title') assert len(soup.find_all('h1')) == 1 # 平台单 h1 契约(与 test_theme_pro_v3 一致) assert soup.select_one('.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]') # 锚点选项卡:Tab 为 #anchor 链接,序列数据驱动(overview 恒有 → 自定义区块 # → Specifications/Features 按数据显隐);首个 active,其余 aria-selected=false tabs = soup.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab') assert len(tabs) == 5 assert [t.get('href') for t in tabs] == ['#overview', '#sec-packing-delivery', '#sec-faq', '#specifications', '#features'] assert 'Product Details' in tabs[0].get_text() assert 'Packing & Delivery' in tabs[1].get_text() and 'FAQ' in tabs[2].get_text() assert 'Specifications' in tabs[3].get_text() and 'Features' in tabs[4].get_text() assert tabs[0].name == 'a' and tabs[0].get('aria-selected') == 'true' assert all(t.get('aria-selected') == 'false' for t in tabs[1:]) assert 'active' in (tabs[0].get('class') or []) # 自定义区块:锚点 section + panel-title 标题(来自数据)+ rich-body 内容 packing = soup.select_one('#sec-packing-delivery.sf-tp-detail-section') assert packing is not None assert packing.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'Packing & Delivery' assert 'Export grade packing on fumigated pallets.' in packing.select_one('.sf-tp-product-body').get_text() faq = soup.select_one('#sec-faq') assert faq is not None and faq.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'FAQ' assert 'Lead time is 20 days' in faq.get_text() # 正文区块:overview 只剩拆出后的正文(区块内容不重复渲染) overview = soup.select_one('#overview') assert 'HP-200 body copy' in overview.get_text() assert 'Export grade packing' not in overview.get_text() and 'Lead time is 20 days' not in overview.get_text() # 静态契约:区块全部平铺(无面板隐藏类/内联样式),panel-title 常显 assert 'sf-tp-tabs-enabled' not in html sections_all = soup.select('.sf-tp-product-tabbed .sf-tp-detail-section') assert len(sections_all) == 5 # overview + 2 自定义区块 + specifications + features assert all(s.get('style') is None for s in sections_all) assert soup.select_one('#overview .sf-tp-panel-title') is not None # 锚点脚本在场(Task 15 重做:平滑滚动 + scrollspy 高亮) assert 'scrollIntoView' in html and "addEventListener('scroll'" in html # 参数表:cms spec-table 每行两对键值(2 条 specs → 1 行 2 th + 2 td); # fields 键值渲染(下划线转空格 + 首字母大写),品牌/结构化 JSON 字段不进表 spec_table = soup.select_one('table.sf-tp-spec-table') assert spec_table is not None rows = spec_table.select('tbody tr') assert len(rows) == 1 assert len(rows[0].select('th')) == 2 and len(rows[0].select('td')) == 2 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 assert 'Sections' not in spec_text and 'Features' not in spec_text and 'Gallery' not in spec_text # Features 区块:fields.features 数据驱动(cms detail-feature-row 同构) feats = soup.select('#features .sf-tp-detail-feature') assert len(feats) == 2 assert feats[0].select_one('h3').get_text(strip=True) == 'High pressure' assert '210 bar continuous rating.' in feats[0].get_text() # 相关产品:复用 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 # ── 旧同步产物兜底:body 尾部 H2 段识别拆出为锚点区块 ── r2 = factory.get(f'/preview/{sid}/{bid}/product/dn200/') assert r2.status_code == 200, r2.text[:400] soup2 = BeautifulSoup(r2.text, 'html.parser') tabs2 = soup2.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab') assert [t.get('href') for t in tabs2] == ['#overview', '#sec-packing-delivery', '#sec-our-exhibition'] assert 'Packing & Delivery' in tabs2[1].get_text() and 'Our Exhibition' in tabs2[2].get_text() assert 'DN200 intro paragraph' in soup2.select_one('#overview').get_text() assert 'Bundled in 6 m lengths.' not in soup2.select_one('#overview').get_text() assert 'Bundled in 6 m lengths.' in soup2.select_one('#sec-packing-delivery').get_text() assert 'Canton Fair' in soup2.select_one('#sec-our-exhibition').get_text() assert soup2.select_one('#specifications') is None # 无 specs 数据不显 Tab assert soup2.select_one('#features') is None # 无 features 数据不显 Tab # ── 无自定义区块的产品:只有 Product Details + Specifications,无 sec-* Tab ── r3 = factory.get(f'/preview/{sid}/{bid}/product/valve/') assert r3.status_code == 200, r3.text[:400] soup3 = BeautifulSoup(r3.text, 'html.parser') tabs3 = soup3.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab') assert [t.get_text() for t in tabs3] == ['Product Details', 'Specifications'] assert all(not (t.get('href') or '').startswith('#sec-') for t in tabs3) # CSS 契约(pro.css 15 块):sticky 锚点选项卡 / scroll-margin 让位 / # 品牌色下划短线 / detail-features 栅格 / rich-body 表格覆盖内联宽高 / # spec-table 移动端横滚 pro_css = (ROOT / 'themes' / 'trade-pro' / 'pro.css').read_text(encoding='utf-8') for needle in ( '.sf-tp-product-tabs{', '.sf-tp-tab.active', 'text-decoration:none', 'scroll-margin-top', '.sf-tp-detail-section > .wrap > h2{', '.sf-tp-detail-features{display:grid', '.sf-tp-detail-feature{display:grid', 'width:100% !important', 'height:auto !important', '.sf-tp-spec-table{min-width:720px}', ): assert needle in pro_css, needle # 旧骨架清理:无 .pro-product-layout/.pro-specs 残留类依赖 assert 'pro-product-layout' not in html and 'pro-specs' not in html def test_trade_pro_listing_layout_structure(factory): # Task 10c:listing.html(category.html 同构复用)移植 cms 列表设计后的结构契约。 # 设计源 D:/project/cms/runtimes/astro-site-runtime/src/pages/[locale]/ # products/[...page].astro(260px 分类侧栏 + 3 列卡片栅格 + 窗口式分页) # news/[...page].astro(310px 侧栏 + 横向新闻行列表 + 侧栏盒组) # 平台契约保留:[data-pro-filter-search] 筛选、.pro-filter-panel 必须是 # .pro-filter-scope 的紧邻前兄弟(pro.js previousElementSibling 定位)、 # 卡片/行携带 .pro-card + data-brand/data-category(pro.js 过滤数据源)。 # 分页器:cms 窗口式页码(首尾 + 当前±2),上一页/下一页为 SVG 箭头。 import io import re sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 # 105 个产品:/categories/pumps/ 走 render_paginated(LIST_PAGE_SIZE=20, # 六页真实静态分页路由),比 101 次 /entries POST 便宜得多 products = [{'type': 'product', 'title': f'Bulk Pump {i:03d}', 'summary': f'Bulk pump model {i:03d} for export.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Pumps'}}} for i in range(105)] posts = [{'type': 'post', 'title': f'Worksite Report {i}', 'summary': f'Installation report from worksite {i}.'} for i in range(3)] payload = json.dumps(products + posts).encode('utf-8') imported = factory.post(f'/api/sites/{sid}/import', files={'file': ('listing-batch.json', io.BytesIO(payload), 'application/json')}) assert imported.status_code == 200, imported.text[:300] assert imported.json()['imported'] == 108, imported.json() 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'] # ── 产品分支(/products/):hero + 分类侧栏 + 3 列卡片栅格 + 筛选契约 ── html = factory.get(f'/preview/{sid}/{bid}/products/').text soup = BeautifulSoup(html, 'html.parser') assert soup.select_one('section.sf-tp-page-hero .sf-tp-page-hero-copy[data-reveal] h1') assert len(soup.find_all('h1')) == 1 side = soup.select_one('.sf-tp-side-panel') assert side is not None and side.find('h3') assert side.select_one('a.sf-tp-side-link[href$="/products/"]') assert side.select_one(f'a.sf-tp-side-link[href$="/categories/pumps/"]') # 筛选契约:面板在 scope 紧邻前兄弟位(pro.js 依赖),卡片带过滤数据 assert soup.select_one('.pro-filter-panel [data-pro-filter-search]') scope = soup.select_one('.pro-filter-scope') assert scope is not None and scope.find_previous_sibling(class_='pro-filter-panel') is not None cards = soup.select('.sf-tp-card-grid--listing a.sf-tp-card.pro-card') assert len(cards) == 105 assert cards[0].get('data-brand') == 'Quanta' and cards[0].get('data-category') == 'Pumps' assert cards[0].select_one('.sf-tp-card-media') and cards[0].select_one('.sf-tp-more svg') # ── 分页(派生分类页走构建期静态分页):窗口页码 + SVG 箭头 ── cat1 = factory.get(f'/preview/{sid}/{bid}/categories/pumps/') assert cat1.status_code == 200, cat1.text[:200] s1 = BeautifulSoup(cat1.text, 'html.parser') assert len(s1.find_all('h1')) == 1 nav1 = s1.select_one('nav.sf-tp-pagination') assert nav1 is not None assert len(s1.select('.sf-tp-card-grid--listing .sf-tp-card')) == 20 active = nav1.select_one('.sf-tp-page-num.is-active[aria-current="page"]') assert active is not None and active.get_text(strip=True) == '1' nxt = nav1.select_one('a[rel="next"]') assert nxt is not None and nxt.get('href', '').endswith('/categories/pumps/page/2/') assert nxt.select_one('svg') is not None # 下一页箭头为 SVG assert nav1.select_one('a[rel="prev"]') is None # 首页无上一页链接 assert nav1.select_one('.sf-tp-page-arrow.is-disabled') is not None # 禁用态占位 cat2 = factory.get(f'/preview/{sid}/{bid}/categories/pumps/page/2/') assert cat2.status_code == 200, cat2.text[:200] s2 = BeautifulSoup(cat2.text, 'html.parser') assert len(s2.find_all('h1')) == 1 nav2 = s2.select_one('nav.sf-tp-pagination') assert len(s2.select('.sf-tp-card-grid--listing .sf-tp-card')) == 20 prev = nav2.select_one('a[rel="prev"]') assert prev is not None and prev.get('href', '').endswith('/categories/pumps/') assert prev.select_one('svg') is not None # 20/页时第 2 页不是末页(105 条 → 6 页),存在下一页链接 nxt2 = nav2.select_one('a[rel="next"]') assert nxt2 is not None and nxt2.get('href', '').endswith('/categories/pumps/page/3/') assert nav2.select_one('.sf-tp-page-num.is-active').get_text(strip=True) == '2' # 页码链接回根路由(第 1 页 = 栏目根,与 cms Pagination 一致) assert nav2.select_one('a.sf-tp-page-num[href$="/categories/pumps/"]') for nav in (nav1, nav2): assert not re.search(r'[‹›→←]', nav.get_text()) # 无裸箭头字符 # ── 文章分支(/news/):新闻行列表 + 310px 侧栏盒组,筛选契约同样保留 ── news = factory.get(f'/preview/{sid}/{bid}/news/').text nsoup = BeautifulSoup(news, 'html.parser') assert len(nsoup.find_all('h1')) == 1 assert nsoup.select_one('.pro-filter-panel [data-pro-filter-search]') nscope = nsoup.select_one('.pro-filter-scope') assert nscope is not None and nscope.find_previous_sibling(class_='pro-filter-panel') is not None rows = nsoup.select('.sf-tp-news-row.pro-card[data-reveal]') assert len(rows) == 3 row = rows[0] assert row.select_one('.sf-tp-news-row-media img, .sf-tp-news-row-media .pro-art') assert row.select_one('h3 a[href$="/post/worksite-report-0/"]') assert row.select_one('a.sf-tp-more svg') # Read more → SVG 箭头 aside = nsoup.select_one('.sf-tp-news-aside') assert aside is not None assert len(aside.select('.sf-tp-aside-box')) == 3 # Latest / Popular / Categories assert aside.select_one('.sf-tp-aside-box a[href$="/post/worksite-report-0/"]') assert not nsoup.select_one('.sf-tp-side-panel') # 文章分支不带产品分类侧栏 assert len(nsoup.select('nav.sf-tp-pagination')) == 0 # 3 条不分页,整段隐藏 # ── 空列表(/cases/ 无案例):整段隐藏换友好空态,不渲染筛选 ── cases = factory.get(f'/preview/{sid}/{bid}/cases/').text assert 'sf-tp-listing-empty' in cases assert 'data-pro-filter-search' not in cases assert 'sf-tp-pagination' not in cases def test_trade_pro_article_layout_structure(factory): # Task 10d:article.html 移植 cms 文章详情设计(news/[slug].astro,像素基准 # dist/hesupipe/en/news//)后的结构契约。构建流(/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.

Packaging

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.

Our standards

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 同款契约,隐藏规则随构建分发);未配置 → # 不渲染任何
,引导卡 CTA 兜底。联系字段有真实数据才渲染: # 邮箱 mailto;电话/地址无数据源绝不虚构。 import re sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 entries = factory.get(f'/api/sites/{sid}/entries').json() contact_id = next(e['id'] for e in entries if e['route'] == '/contact/') patched = factory.patch(f'/api/sites/{sid}/entries/{contact_id}', json={ 'title': 'Contact Us', 'summary': 'Product enquiries, export logistics and partnership requests.', 'body': '

Reach the trade desk for specifications, pricing and delivery schedules.

', 'cover': 'https://imgs.example/contact-hall.jpg'}) assert patched.status_code == 200, patched.text # HTTPS 双层校验第一层:非 HTTPS 端点被 API 拒绝 bad = factory.put(f'/api/sites/{sid}/contact-settings', json={ 'email': 'sales@example.org', 'form_endpoint': 'http://unsecure.example/lead'}) assert bad.status_code == 400 ok = factory.put(f'/api/sites/{sid}/contact-settings', json={ 'email': 'sales@example.org', 'form_endpoint': 'https://forms.example.org/leads'}) assert ok.status_code == 200, ok.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'] # ── 形态一(已配置外部表单):hero + 双栏 + 表单 + 蜜罐 ── html = factory.get(f'/preview/{sid}/{bid}/contact/').text soup = BeautifulSoup(html, 'html.parser') assert len(soup.find_all('h1')) == 1 hero = soup.select_one('section.sf-tp-page-hero.sf-tp-contact-hero') assert hero is not None bg = hero.select_one('.sf-tp-page-hero-bg') assert bg is not None and 'contact-hall.jpg' in bg.get('src', '') current = hero.select_one('.sf-tp-breadcrumb-current') assert current is not None and current.get_text(strip=True) == 'Contact Us' assert 'Product enquiries' in hero.select_one('.sf-tp-page-hero-lead').get_text() # 左栏:联系信息(眉题 + h2 + 真实邮箱 mailto + SVG 图标条目) grid = soup.select_one('.sf-tp-contact-grid') assert grid is not None info = grid.select_one('.sf-tp-contact-info[data-reveal]') assert info is not None and info.select_one('.eyebrow') and info.find('h2') assert info.select_one('.sf-tp-contact-item a[href="mailto:sales@example.org"]') is not None items = info.select('.sf-tp-contact-item') assert len(items) >= 2 and all(i.select_one('.icon-circle svg') for i in items) # 电话/地址无数据源 → 不渲染(绝不虚构) assert 'tel:' not in html # 右栏:外部表单(全页唯一 form)name/email/message + 隐藏蜜罐 card = grid.select_one('.sf-tp-contact-form[data-reveal]') assert card is not None forms = soup.find_all('form') assert len(forms) == 1 form = forms[0] assert form.get('action') == 'https://forms.example.org/leads' and form.get('method') == 'POST' for name in ('name', 'email', 'message'): assert form.select_one(f'[name={name}]') is not None, name honeypot = form.select_one('input.pro-honeypot') assert honeypot is not None and honeypot.get('aria-hidden') == 'true' and honeypot.get('tabindex') == '-1' # 蜜罐隐藏规则随构建分发(10b 修复轮的 .pro-honeypot CSS) pro_css = factory.get(f'/preview/{sid}/{bid}/assets/pro.css').text assert '.sf-theme-trade-pro .pro-honeypot' in pro_css and 'left:-9999px' in pro_css # 正文:body 存在才渲染(10d 排版层双挂) body = soup.select_one('.sf-tp-contact-body .pro-rich') assert body is not None and 'trade desk' in body.get_text() # 交互箭头一律 SVG(Task 9 契约) assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))) # ── 形态二(未配置):无任何 ,引导卡兜底 + 无虚构联系方式 ── sid2 = site(factory) assert factory.post(f'/api/factory/sites/{sid2}/bootstrap').status_code == 200 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}/contact/') assert bare.status_code == 200, bare.text[:300] bsoup = BeautifulSoup(bare.text, 'html.parser') assert len(bsoup.find_all('form')) == 0 # 无假提交:未配置即不渲染任何表单 assert 'mailto:' not in bare.text # 无邮箱数据 → 无 mailto(含页脚) assert 'tel:' not in bare.text # 绝不虚构电话 card2 = bsoup.select_one('.sf-tp-contact-form') assert card2 is not None # 引导卡仍在(CTA 引导) assert card2.select_one('a.sf-tp-btn[href$="/products/"]') is not None assert len(bsoup.find_all('h1')) == 1 def test_trade_pro_case_layout_structure(factory): # Task 10g:case.html 移植 cms 案例详情设计(projects/[slug].astro,像素基准 # dist/hesupipe/en/projects//)后的结构契约。 # 区块序列:PageHero(面包屑 Home › Cases › 标题 + 封面底图 + 眉题/h1/导语) # → detail-grid 双栏(1.2fr 文案列:Project Overview 眉题 + h2 + ● location/ # industry 行 + 富文本正文;0.8fr sticky 侧列:主图 + 3 列相册缩略图 + # Results 成果卡)→ Recommended Products(产品卡,空则隐藏)→ Other # Projects(groups['case'] 排除自身,空则隐藏)。页尾 Cta 由 pro.html 的 # pro-end-cta 覆盖。cms 的结构化字段(challenge/solution/results 词典兜底) # 平台无对应数据源:正文分段由富文本自身标题承担,成果卡由 fields.results # 开放字段驱动(分号/换行分隔要点),空则整段隐藏——绝不渲染词典兜底文案。 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': 'case', 'title': 'City Drinking Water Project', 'route': '/case/city-water/', 'cover': 'https://imgs.example/city-water.jpg', 'summary': 'Municipal supply network upgrade in Jakarta.', 'body': '

The network demanded reliable joint performance under tropical site conditions.

The Solution

A complete piping system was delivered and supervised on site.

', 'meta': {'fields': {'location': 'Jakarta, Indonesia', 'industry': 'Municipal water supply', 'results': 'Delivered on schedule;Passed third-party inspection', 'gallery': 'https://imgs.example/city-water.jpg; https://imgs.example/city-water-b.jpg; https://imgs.example/city-water-c.jpg'}}}) assert made.status_code in (200, 201), made.text for i in range(2): r = factory.post(f'/api/sites/{sid}/entries', json={ 'type': 'case', 'title': f'Metro Water Supply {i}', 'route': f'/case/metro-{i}/', 'summary': f'Reference project {i}.'}) assert r.status_code in (200, 201), r.text # 1 个产品驱动 Recommended Products 区 prod = factory.post(f'/api/sites/{sid}/entries', json={ 'type': 'product', 'title': 'HDPE Pipe PE100', 'summary': 'Pressure pipe for water supply.'}) assert prod.status_code in (200, 201), prod.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}/case/city-water/') assert r.status_code == 200, r.text[:400] html = r.text soup = BeautifulSoup(html, 'html.parser') # hero:封面底图 + 面包屑(Home › Cases › 标题)+ 眉题 + 唯一 h1 hero = soup.select_one('section.sf-tp-page-hero.sf-tp-case-hero') assert hero is not None bg = hero.select_one('.sf-tp-page-hero-bg') assert bg is not None and 'city-water.jpg' in bg.get('src', '') assert hero.select_one('.sf-tp-breadcrumb a[href$="/cases/"]') current = hero.select_one('.sf-tp-breadcrumb-current') assert current is not None and current.get_text(strip=True) == 'City Drinking Water Project' assert hero.select_one('.sf-tp-page-hero-copy[data-reveal] h1') assert 'Municipal supply network upgrade' in hero.select_one('.sf-tp-page-hero-lead').get_text() assert len(soup.find_all('h1')) == 1 # detail 双栏:文案列(概览眉题 + h2 + ● location/industry 行 + 富文本正文) grid = soup.select_one('.sf-tp-case-grid') assert grid is not None copy = grid.select_one('.sf-tp-case-copy') assert copy is not None and 'project overview' in copy.select_one('.eyebrow').get_text().lower() assert copy.find('h2') is not None and 'City Drinking Water' in copy.find('h2').get_text() locations = copy.select('.sf-tp-case-location') assert len(locations) == 2 # location + industry(blueprints 的 case 字段) assert 'Jakarta, Indonesia' in locations[0].get_text() assert 'Municipal water supply' in locations[1].get_text() body = copy.select_one('.sf-tp-case-body .pro-rich') assert body is not None and 'The Solution' in body.get_text() # 侧列:主图 gallery[0] + 全量相册缩略图(cms 同款含首图)+ Results 成果卡 side = grid.select_one('.sf-tp-case-side') assert side is not None photo = side.select_one('.sf-tp-photo img') assert photo is not None and 'city-water.jpg' in photo.get('src', '') thumbs = side.select('.sf-tp-case-gallery img') assert len(thumbs) == 3 # cover 去重 + b + c results = side.select_one('.sf-tp-case-results') assert results is not None and 'Results' in results.find('h3').get_text() points = results.select('li') assert len(points) == 2 assert all(p.select_one('.sf-tp-case-check svg') for p in points) # ✓ 为 SVG 图标 assert 'Passed third-party inspection' in points[1].get_text() # Recommended Products:产品卡(无产品整段隐藏) products = soup.select_one('.sf-tp-case-products') assert products is not None assert any('HDPE Pipe PE100' in c.get_text() for c in products.select('.sf-tp-card')) # Other Projects:相关案例去自身 related = soup.select_one('.sf-tp-case-related') assert related is not None cards = related.select('.sf-tp-card') assert len(cards) == 2 assert all('City Drinking Water' not in c.get_text() for c in cards) assert related.select_one('a.sf-tp-more[href$="/cases/"] svg') # View all projects → SVG 箭头 # 交互箭头一律 SVG(Task 9 契约) assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))) # 本构建的链接全通(新增 /cases/ 面包屑与相关案例路由不产生断链) report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json() assert not report['errors'] and not report['broken_links'] and not report['missing_local_images'] # ── 案例列表分支(10c listing 的 case 类走产品卡栅格;10g 补案例口径 # hero 文案:cms projects 列表 OUR PROJECTS,非 PRODUCT CATALOGUE)── cases_html = factory.get(f'/preview/{sid}/{bid}/cases/').text assert 'OUR PROJECTS' in cases_html assert 'PRODUCT CATALOGUE' not in cases_html csoup = BeautifulSoup(cases_html, 'html.parser') assert len(csoup.select('.sf-tp-card-grid--listing .sf-tp-card')) == 3 # ── 空数据案例(无 cover/gallery/body/fields/其他案例/产品):相册、 # 成果卡、推荐产品、相关案例整段隐藏;占位图保持版式完整 ── sid2 = site(factory) bare = factory.post(f'/api/sites/{sid2}/entries', json={ 'type': 'case', 'title': 'Reference Project', 'route': '/case/reference/'}) assert bare.status_code in (200, 201), bare.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'] page2 = factory.get(f'/preview/{sid2}/{bid2}/case/reference/') assert page2.status_code == 200, page2.text[:300] bsoup = BeautifulSoup(page2.text, 'html.parser') assert len(bsoup.find_all('h1')) == 1 assert bsoup.select_one('.sf-tp-page-hero-bg') is None assert bsoup.select_one('.sf-tp-case-gallery') is None assert bsoup.select_one('.sf-tp-case-results') is None assert bsoup.select_one('.sf-tp-case-products') is None assert bsoup.select_one('.sf-tp-case-related') is None assert bsoup.select_one('.sf-tp-case-side .sf-tp-photo-plain') is not None # 无图占位 assert bsoup.select_one('.sf-tp-case-location') is None # 字段空则行隐藏 def test_trade_pro_standard_layout_structure(factory): # Task 10h:standard.html 移植 cms 通用内容页设计(applications.astro + # support.astro,像素基准 dist/hesupipe/en/{applications,support}/)后的 # 结构契约。standard 是所有非专属 kind 的兜底模板,两页共性形态: # PageHero(眉题/h1/导语/页内锚点按钮 + 封面底图)→ 内容区。 # 与 cms 的结构决策:两页内容区的应用卡网格 / FeatureGrid / 锚点导航 / # 文档卡 / FAQ 全是 HESU 词典内容而非布局——跨站即假数据,不移植; # 平台 standard 页内容 = 富文本 body,小节结构由正文自身 h2 承担 # (10g 案例详情同决策),排版层双挂 sf-tp-article-body(10d)。 # body 为空 → 正文区块与 hero 锚点按钮整段隐藏(cms isRichAbout 三元 # 分支同款)。meta.page_template='standard' 优先于路由前缀推断 # (infer_kind 显式分支先于 /services/ 前缀)——模板不绑路由: # bootstrap 的 /services/ 页(meta standard)渲染本模板而非 service 模板。 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': 'page', 'title': 'Applications', 'route': '/applications/', 'cover': 'https://imgs.example/water-treatment.jpg', 'summary': 'Piping systems for infrastructure, energy, industry and agriculture.', 'body': '

Our systems serve municipal, agricultural and industrial projects worldwide.

Municipal engineering

Reliable piping for urban utility networks.

'}) assert made.status_code in (200, 201), made.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'] # ── 主流程:普通页面条目(无 meta.page_template,infer_kind 兜底回落 # standard)→ hero + 正文区块 ── r = factory.get(f'/preview/{sid}/{bid}/applications/') assert r.status_code == 200, r.text[:400] html = r.text soup = BeautifulSoup(html, 'html.parser') # hero:面包屑(Home › 标题)+ 封面底图 + 眉题 + 唯一 h1 + 导语 hero = soup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero') assert hero is not None bg = hero.select_one('.sf-tp-page-hero-bg') assert bg is not None and 'water-treatment.jpg' in bg.get('src', '') 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) == 'Applications' assert hero.select_one('.sf-tp-page-hero-copy[data-reveal] h1') assert 'Piping systems for infrastructure' in hero.select_one('.sf-tp-page-hero-lead').get_text() assert len(soup.find_all('h1')) == 1 # cms PageHero 页内锚点按钮(applications/support 均有):锚到正文区块, # 箭头为 SVG(Task 9 契约) btn = hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-standard-body"]') assert btn is not None and btn.select_one('svg') is not None # 正文区块:SectionHead(眉题 + 页题 h2——cms about-body 的 SectionHead # title={heroTitle} 同构)+ 富文本正文(10d 排版层双挂),正文 h2 承担小节 body_section = soup.select_one('section.sf-tp-standard-body-section#tp-standard-body') assert body_section is not None head = body_section.select_one('.sf-tp-section-head') assert head is not None and head.select_one('.eyebrow') and head.find('h2') body = body_section.select_one('.sf-tp-standard-body.sf-tp-article-body .pro-rich') assert body is not None and 'Municipal engineering' in body.get_text() # 旧骨架(Phase 2 pro-info 双栏)无残留 assert 'pro-info-hero' not in html and 'pro-info-section' not in html # 交互箭头一律 SVG(Task 9 契约) assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))) # 本构建链接全通(面包屑与锚点按钮不产生断链) report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json() assert not report['errors'] and not report['broken_links'] and not report['missing_local_images'] # ── 空数据 + 模板不绑路由:bootstrap 的 /services/ 页 meta.page_template # ='standard'(显式 meta 优先于路由前缀推断),无 body → 正文区块与 # 锚点按钮整段隐藏;渲染 standard 模板而非 service 模板 ── services = factory.get(f'/preview/{sid}/{bid}/services/') assert services.status_code == 200, services.text[:300] ssoup = BeautifulSoup(services.text, 'html.parser') assert len(ssoup.find_all('h1')) == 1 assert ssoup.select_one('section.sf-tp-page-hero.sf-tp-standard-hero') is not None assert 'pro-info-hero' not in services.text # service 模板骨架未渲染 assert ssoup.select_one('.sf-tp-standard-body-section') is None # 无 body 整段隐藏 assert '#tp-standard-body' not in services.text # 锚点按钮随正文隐藏 assert ssoup.select_one('.sf-tp-page-hero-bg') is None # 无封面 → 无底图 assert ssoup.select_one('.sf-tp-page-hero-lead') is not None # bootstrap 摘要在导语位 def test_trade_pro_chrome_layout(factory): # Task 10j:cms 页眉/页脚 chrome 视觉还原 + 手机端抽屉导航。 # 设计源 D:/project/cms/runtimes/astro-site-runtime/src/components/ # Header.astro(玻璃吸顶 + 右对齐半粗导航 + 悬停下划线/下拉卡 + 移动 # 右滑抽屉)/ Footer.astro(深色四栏 + 版权条),10j 以纯样式层把平台 # chrome DOM(sf-site-head/pro-header/pro-navigation/pro-footer)重塑为 # cms 设计——平台数据流(site_design 导航/公告/CTA/页脚选项)与 pro.js # 交互钩子(data-pro-menu/data-pro-catalog/data-pro-mega/pro-nav-disclose) # 全部保留。抽屉复用上游 V8.21.3 四主题机制(commercial.css 样式段 + # pro.js 通用逻辑:选择器寻钩子而非主题分支,trade-pro 输出同款类名 # 结构即自动可用);mobile_tabs 为模板驱动,10j 给 trade-pro 补上 # 渲染块后配置即生效(下方发布前后双构建断言)。 import re sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 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'] html = factory.get(f'/preview/{sid}/{bid}/').text soup = BeautifulSoup(html, 'html.parser') # ── 平台 chrome 结构与交互钩子保留(数据流不破坏的结构证据)── header = soup.select_one('header.sf-site-head.pro-header') assert header is not None assert header.select_one('.pro-wrap.pro-head-inner') is not None assert header.select_one('a.pro-logo[href$="/"]') is not None # /preview/ 重写根相对 href,用后缀匹配 toggle = header.select_one('button.pro-mobile-toggle[data-pro-menu]') assert toggle is not None and toggle.get('aria-controls') == 'pro-navigation' nav = header.select_one('nav#pro-navigation.pro-navigation') assert nav is not None and nav.get('aria-label') # catalog/mega 钩子(aria-controls 对应 mega 面板 id;面板本体按 categories 条件渲染) catalog = header.select_one('button.pro-catalog-toggle[data-pro-catalog]') assert catalog is not None and catalog.get('aria-controls') == 'pro-mega-content' # CTA 双挂 sf-tp-btn(cms 蓝按钮形态)+ chrome.header CTA 数据位 cta = header.select_one('a.pro-header-cta.sf-tp-btn') assert cta is not None and cta.get('href', '').endswith('/contact/') # 公告条数据位仍在(未发布 site_design 时渲染英文兜底文案) assert soup.select_one('.pro-utility .pro-wrap') is not None # ── 英文兜底导航(tp_nav):7 项默认栏目全渲染(尾部可跟 Search 链接—— # chrome.header.show_search 数据位默认开)── nav_links = nav.select(':scope > a, :scope > .pro-nav-group > a') nav_text = [a.get_text(strip=True) for a in nav_links] assert nav_text[:7] == ['Home', 'Products', 'Brands', 'News', 'Cases', 'About', 'Contact'] assert nav_text[7:] in ([], ['Search']) # ── 页脚:平台 DOM + cms 栏目标题(Quick Links 随 footer_nav 数据流) footer = soup.select_one('footer.sf-site-foot.pro-footer') assert footer is not None assert footer.select_one('.pro-footer-top') is not None heading = footer.select_one('.pro-footer-links h3') assert heading is not None and heading.get_text(strip=True) == 'Quick Links' footer_nav = footer.select('.pro-footer-links ul li a') assert len(footer_nav) == 7 and footer_nav[0].get_text(strip=True) == 'Home' assert footer.select_one('.pro-footer-bottom') is not None # ── 10j 视觉层随构建分发(pro.css:cms chrome 映射的样式证据)── pro_css = factory.get(f'/preview/{sid}/{bid}/assets/pro.css').text assert 'Task 10j' in pro_css # 玻璃拟态吸顶(Header.astro .site-header) assert re.search(r'\.sf-theme-trade-pro \.pro-header\{[^}]*position:sticky', pro_css) assert 'backdrop-filter:blur(10px)' in pro_css # 导航:cms 右对齐半粗 + 悬停下划线(.nav-links a 的 border-block-end 方案) assert re.search(r'\.sf-theme-trade-pro \.pro-navigation \.pro-nav-group>a[^{]*\{[^}]*border-block-end', pro_css) assert 'var(--sf-tp-color-border-brand)' in pro_css # 下拉卡(.sub-menu):白卡 + 发丝边 + radius-sm + shadow-md 的令牌引用 assert re.search(r'\.sf-theme-trade-pro \.pro-nav-children\{[^}]*var\(--sf-tp-color-surface-default\)', pro_css) # 深色页脚(.site-footer 令牌桥:surface-inverse + 版权条 border-inverse) assert re.search(r'\.sf-theme-trade-pro \.pro-footer\{[^}]*var\(--sf-tp-color-surface-inverse\)', pro_css) assert 'var(--sf-tp-color-border-inverse)' in pro_css # 抽屉品牌化变量桥:平台 drawer 样式段消费 --pro-* 变量,10j 注入 cms 令牌 assert '--pro-background:var(--sf-tp-color-surface-default)' in pro_css # ── 上游抽屉机制复用(commercial.css/pro.js 随构建分发 + 安全区适配)── assert 'assets/commercial.css' in html and 'assets/pro.js' in html commercial = factory.get(f'/preview/{sid}/{bid}/assets/commercial.css').text assert '.sf-mobile-drawer-backdrop' in commercial and 'sf-drawer-open' in commercial assert 'env(safe-area-inset-bottom' in commercial # 抽屉/底部 tabs 的安全区适配 pro_js = factory.get(f'/preview/{sid}/{bid}/assets/pro.js').text assert 'sf-drawer-header' in pro_js and 'sf-drawer-extra' in pro_js and 'Escape' in pro_js # ── mobile_tabs:发布 site_design 前不渲染,发布后渲染(含 menu 动作开抽屉)── assert 'data-mobile-tabs' not in html import copy from app import site_design_v87 config = copy.deepcopy(site_design_v87.DEFAULTS) config['mobile_tabs'] = {'enabled': True, 'items': [ {'title': 'Home', 'icon': 'home', 'route': '/', 'action': 'link'}, {'title': 'Products', 'icon': 'products', 'route': '/products/', 'action': 'link'}, {'title': 'Menu', 'icon': 'menu', 'route': '/', 'action': 'menu'}]} url = f'/api/sites/{sid}/site-design' assert factory.put(url, json={'config': config, 'expected_revision': 0}).status_code == 200 assert factory.post(url + '/publish', json={'expected_revision': 1}).status_code == 200 bid2 = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version'] html2 = factory.get(f'/preview/{sid}/{bid2}/').text assert 'data-mobile-tabs' in html2 and 'data-mobile-open-menu' in html2 tabs = BeautifulSoup(html2, 'html.parser').select('.sf-mobile-tabs .sf-mobile-tab') assert len(tabs) == 3 assert [t.select_one('.sf-tab-label').get_text(strip=True) for t in tabs] == ['Home', 'Products', 'Menu'] assert tabs[0].select_one('.sf-tab-icon') is not None # 图标位渲染 # 发布后 chrome_is_custom 生效:公告条走配置数据位(announcement 文案 + # show_announcement 开关)。publish 会清空草稿 → 新 PUT 从 revision 0 重新开始 announced = {**config, 'header': {**config['header'], 'announcement': 'Global sourcing update', 'show_announcement': True}} assert factory.put(url, json={'config': announced, 'expected_revision': 0}).status_code == 200 assert factory.post(url + '/publish', json={'expected_revision': 1}).status_code == 200 bid3 = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version'] html3 = factory.get(f'/preview/{sid}/{bid3}/').text assert 'Global sourcing update' in html3 # 本构建链接全通(chrome 改动不产生断链) report = factory.get(f'/api/sites/{sid}/builds/{bid3}/report').json() assert not report['errors'] and not report['broken_links'] 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 ' 指向 assets/blocks.css; # 2) 结构:多类型区块页(layout_mode=replace 纯区块渲染)输出全部 sf-* # 区块 DOM,pro.js 筛选数据钩子(data-category/data-brand/ # [data-sf-filterable])与 YouTube 白名单改写保留; # 3) 视觉:.sf-theme-trade-pro 作用域的 sf-* 覆盖规则齐全、--sf-tp-* # 令牌被引用、模板断点(900/620)齐备,平台基础层(slider/布局栅格/ # 设备断点 820)完整保留,且不触碰编辑器画布钩子(data-block / # sf-canvas-selected / 区块级 outline)。 import re from app import blueprints sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 img = 'https://imgs.example/bridge.jpg' blocks = [ blueprints.new_block('hero', {'title': 'Global Sourcing Hub', 'subtitle': 'TRADE PRO', 'description': 'Verified manufacturers.', 'image': img, 'button': 'Explore'}), blueprints.new_block('stats', {'items': [{'value': '12+', 'label': 'Years'}, {'value': '40+', 'label': 'Countries'}, {'value': '98%', 'label': 'On time'}, {'value': '120', 'label': 'SKUs'}]}), blueprints.new_block('features', {'title': 'Why Us', 'items': [{'title': 'QC', 'text': 'Inspection.'}, {'title': 'Logistics', 'text': 'FOB.'}, {'title': 'Support', 'text': 'English desk.'}]}), blueprints.new_block('collection', {'title': 'Featured', 'subtitle': 'PRODUCTS', 'source': 'product', 'limit': 4}), blueprints.new_block('image_text', {'title': 'Factory Direct', 'subtitle': 'ABOUT', 'description': 'Own production lines.', 'image': img}), blueprints.new_block('cta', {'title': 'Request a Quote', 'subtitle': 'Send specifications.'}), blueprints.new_block('gallery', {'title': 'Workshop', 'images': [{'url': img, 'alt': 'Line'}, {'url': img, 'alt': 'Goods'}, {'url': img, 'alt': 'Lab'}]}), blueprints.new_block('faq', {'title': 'FAQ', 'items': [{'question': 'MOQ?', 'answer': 'One container.'}]}), blueprints.new_block('richtext', {'title': 'Terms', 'body': '

Incoterms 2020 apply.

'}), blueprints.new_block('carousel', {'title': 'Showcase', 'items': [{'image': img, 'alt': 'Shot', 'caption': 'Line 1'}]}), blueprints.new_block('video', {'title': 'Plant Tour', 'url': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'}), blueprints.new_block('pricing', {'title': 'Plans', 'items': [{'name': 'Bulk', 'price': 'FOB', 'features': ['QC', 'Packing']}]}), blueprints.new_block('tabs', {'title': 'Details', 'items': [{'label': 'Specs', 'text': 'PE100 raw material.'}]}), blueprints.new_block('spacer', {'height': 24}), ] for i in range(2): made = factory.post(f'/api/sites/{sid}/entries', json={ 'type': 'product', 'title': f'Bridge Valve {i}', 'route': f'/product/bridge-{i}/', 'cover': img, 'summary': 'Bridge card.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Valves'}}}) assert made.status_code in (200, 201), made.text page = factory.post(f'/api/sites/{sid}/entries', json={ 'type': 'page', 'title': 'Bridge Demo', 'route': '/bridge-demo/', 'meta': {'page_template': 'standard', 'layout_mode': 'replace', 'blocks': blocks}}) assert page.status_code in (200, 201), page.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'] # ── 1) 分发:主题版原文(非平台版)随构建输出 ── html = factory.get(f'/preview/{sid}/{bid}/bridge-demo/').text assert 'assets/blocks.css' in html served = factory.get(f'/preview/{sid}/{bid}/assets/blocks.css').text theme_blocks_css = (ROOT / 'themes' / 'trade-pro' / 'blocks.css').read_text(encoding='utf-8') platform_blocks_css = (ROOT / 'app' / 'static' / 'blocks.css').read_text(encoding='utf-8') assert served == theme_blocks_css and served != platform_blocks_css # ── 2) 结构:全部区块类型渲染 + 平台数据钩子保留 ── soup = BeautifulSoup(html, 'html.parser') assert 'sf-theme-trade-pro' in (soup.body.get('class') or []) assert soup.select_one('main.sf-canvas.pro-custom-content') is not None for sel in ('section.sf-hero .sf-hero-grid .sf-hero-copy h1', 'section.sf-hero .sf-hero-img img', 'section.sf-stats .sf-stats strong', 'section.sf-features .sf-feature h3', 'section.sf-collection .sf-item-copy h3', 'section.sf-image_text .sf-image-side img', 'section.sf-cta .sf-cta-wrap .sf-button', 'section.sf-gallery .sf-gallery img', 'section.sf-faq details summary', 'section.sf-richtext .sf-prose', 'section.sf-carousel .sf-slides figure.sf-slide', 'section.sf-video .sf-video iframe', 'section.sf-pricing .sf-price strong', 'section.sf-tabs .sf-tab-list details.sf-tab', 'div.sf-spacer'): assert soup.select_one(sel) is not None, sel cards = soup.select('.sf-collection[data-sf-filterable] a.sf-item[data-category][data-brand]') assert len(cards) == 2 and cards[0]['data-brand'] == 'Quanta' assert 'youtube-nocookie.com/embed/dQw4w9WgXcQ' in html # ── 3) 视觉桥接:作用域规则 + 令牌 + 断点 + 基础层红线 ── for scoped in ('.sf-theme-trade-pro .sf-canvas .sf-section.sf-hero{', '.sf-theme-trade-pro .sf-feature{', '.sf-theme-trade-pro .sf-item{', '.sf-theme-trade-pro .sf-image-text{', '.sf-theme-trade-pro .sf-container .sf-stats{', '.sf-theme-trade-pro .sf-canvas .sf-section.sf-cta{', '.sf-theme-trade-pro .sf-gallery{', '.sf-theme-trade-pro .sf-prose{', '.sf-theme-trade-pro .sf-slides{', '.sf-theme-trade-pro .sf-container .sf-video{', '.sf-theme-trade-pro .sf-price{', '.sf-theme-trade-pro .sf-button{'): assert scoped in served, scoped for token in ('--sf-tp-color-surface-inverse', '--sf-tp-color-brand-primary', '--sf-tp-color-brand-primary-deep', '--sf-tp-color-text-inverse', '--sf-tp-color-text-brand', '--sf-tp-color-text-muted', '--sf-tp-color-surface-muted', '--sf-tp-color-border-subtle', '--sf-tp-radius-md', '--sf-tp-shadow-md', '--sf-tp-ratio-landscape', '--sf-tp-space-section-y', '--sf-tp-font-size-2xl', '--sf-tp-button-height-md', '--sf-tp-duration-fast', '--sf-tp-easing-standard'): assert f'var({token})' in served, token assert len(set(re.findall(r'var\(--sf-tp-[a-z0-9-]+\)', served))) >= 25 assert '@media (max-width: 900px)' in served and '@media (max-width: 620px)' in served # 平台功能层完整保留:slider / 布局栅格 / 设备断点(responsive.py 依赖 820/540 语义) for base_rule in ('.sf-hero-track{', '.sf-layout-grid{', '.sf-hero-controls{', '@media(max-width:820px)'): assert base_rule in served, base_rule # 编辑器画布与区块 outline 红线:主题层不得触碰选择器钩子与区块级 outline assert 'data-block' not in served and 'sf-canvas-selected' not in served assert not re.search(r'\.sf-section\.sf-[a-z-]+\s*\{[^}]*outline', served) # 本构建链接全通 report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json() assert not report['errors'] and not report['broken_links'] def test_trade_pro_home_block_driven(factory): # Task 13b:首页从模板驱动切换为区块驱动(layout_mode=replace)。 # 序列对照 home.html 模板设计:hero → trust_bar(新增)→ image_text+stats → # collection(product,4) → applications_band(新增)→ features → # collection(post,3) → cta。写入走真实 studio-draft 链路(PUT 草稿 → # publish 发布 → 构建),与运维脚本 scripts/setup_home_blocks.py 同一条 # 代码路径。home.html 模板文件保留(其他站点仍可用),仅本站首页 replace。 import io from app import blueprints sid = site(factory) assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200 img = 'https://imgs.example/hesu-plant.jpg' batch = [{'type': 'product', 'title': f'HDPE Pipe PE100 {i}', 'summary': f'Pressure pipe {i} for municipal water supply.', 'cover': img, 'meta': {'fields': {'brand': 'HESU', 'category': 'Pipes'}}} for i in range(4)] batch += [{'type': 'post', 'title': f'Export Update {i}', 'summary': f'Dock loading report {i}.'} for i in range(3)] payload = json.dumps(batch).encode('utf-8') imported = factory.post(f'/api/sites/{sid}/import', files={'file': ('home-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 home = next(e for e in factory.get(f'/api/sites/{sid}/entries').json() if e['route'] == '/') blocks = [ blueprints.new_block('hero', {'title': 'Reliable piping systems, exported worldwide', 'subtitle': 'GLOBAL SOURCING · MADE RELIABLE', 'description': 'Specifications, certification and packaging published with every listing.', 'image': img, 'button': 'Browse products', 'href': '/products/'}), blueprints.new_block('trust_bar', {}), blueprints.new_block('image_text', {'title': 'One partner from inquiry to delivery.', 'subtitle': 'ABOUT US', 'image': img, 'description': 'Product specifications, quality control and export documentation in one place.', 'button': 'About our company', 'href': '/about/'}), blueprints.new_block('stats', {'items': [{'value': '48', 'label': 'Products'}, {'value': '12', 'label': 'Categories'}, {'value': '30', 'label': 'Markets served'}, {'value': '10', 'label': 'Export years'}]}), blueprints.new_block('collection', {'title': 'Featured products', 'subtitle': 'TRADE CATALOGUE', 'source': 'product', 'limit': 4, 'columns': 4, 'image_fit': 'cover', 'button': 'Browse all products', 'href': '/products/'}), blueprints.new_block('applications_band', {}), blueprints.new_block('features', {'title': 'Why buyers stay with us', 'items': [ {'title': 'Verified sourcing', 'text': 'Suppliers audited against your specifications before the first sample.'}, {'title': 'Strict quality control', 'text': 'Pre-shipment inspection with photographic evidence for every batch.'}, {'title': 'Export documentation', 'text': 'Certification, packing lists and customs papers prepared in-house.'}, {'title': 'Dedicated support', 'text': 'One trade desk contact from inquiry to container loading.'}]}), blueprints.new_block('collection', {'title': 'Latest updates', 'subtitle': 'COMPANY NEWS', 'source': 'post', 'limit': 3, 'columns': 3, 'image_fit': 'cover', 'button': 'View all news', 'href': '/news/'}), blueprints.new_block('cta', {'title': 'Send one inquiry, get a matched quote.', 'subtitle': 'Share your specifications and target market; our trade desk replies within one business day.', 'button': 'Request a quote', 'href': '/contact/'}), ] put = factory.put(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft', json={'blocks': blocks, 'page_template': 'home', 'layout_mode': 'replace', 'expected_revision': 0}) assert put.status_code == 200, put.text pub = factory.post(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft/publish', json={'expected_revision': 1}) assert pub.status_code == 200, pub.text state = factory.get(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft').json() assert state['state'] == 'published' and state['layout_mode'] == 'replace' bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version'] html = factory.get(f'/preview/{sid}/{bid}/').text soup = BeautifulSoup(html, 'html.parser') # ── 8 种区块全部在场(在画布容器内,编辑器结构契约不破坏)── canvas = soup.select_one('main.sf-canvas.pro-custom-content') assert canvas is not None for sel in ('section.sf-hero', 'section.sf-trust_bar', 'section.sf-image_text', 'section.sf-stats', 'section.sf-applications_band', 'section.sf-features', 'section.sf-cta'): assert canvas.select_one(sel) is not None, sel collections = canvas.select('section.sf-collection') assert len(collections) == 2 assert len(collections[0].select('a.sf-item')) == 4 # 产品精选 4 卡 assert len(collections[1].select('a.sf-item')) == 3 # 新闻 3 卡 assert 'HDPE Pipe PE100 0' in collections[0].get_text() assert 'Export Update' in collections[1].get_text() # ── 新区块结构契约:信任条(icon-circle+标题+说明+分隔线)与应用带(2×2 瓦片)── trust = canvas.select('.sf-trust-bar .sf-trust-item') assert len(trust) == 4 assert all(t.select_one('.sf-trust-icon') and t.find('strong') and t.select_one('.sf-trust-text') for t in trust) assert 'Certified quality' in trust[0].get_text() and 'Responsive service' in trust[3].get_text() tiles = canvas.select('.sf-applications-band .sf-app-tile') assert len(tiles) == 4 assert all(t.select_one('.sf-app-icon') and t.find('h3') and t.find('p') for t in tiles) assert 'Water supply' in tiles[0].get_text() and 'Mining' in tiles[3].get_text() # ── 模板不再渲染:home.html 的模板级标记全数缺席(chrome 的 sf-tp-btn 不在此列)── for marker in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col', 'sf-tp-card-grid', 'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta'): assert marker not in html, marker assert len(soup.find_all('h1')) == 1 # 区块 hero 提供唯一 h1,模板缺席不重复 # ── 桥接层语汇:新区块的 trade-pro 视觉规则 + sf-tp 令牌随构建分发 ── served = factory.get(f'/preview/{sid}/{bid}/assets/blocks.css').text for rule in ('.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{', '.sf-theme-trade-pro .sf-trust-item{', '.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{', '.sf-theme-trade-pro .sf-app-tile{'): assert rule in served, rule assert 'var(--sf-tp-color-surface-inverse)' in served assert 'var(--sf-tp-color-text-brand)' in served # 编辑器画布红线(13a 契约延续):新区块样式不触碰画布钩子 assert 'data-block' not in served and 'sf-canvas-selected' not in served # ── 构建报告零错误 ── report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json() assert not report['errors'] and not report['broken_links'] and not report['missing_local_images'] 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