feat: port cms listing layout into trade-pro (kind-branched cards/filters/pagination)
Task 10c. listing.html (category.html now includes it) replaces the thin pro-inner-hero variant with the cms list designs: - product-like listings (product/vehicle/brand/case/service) get the products/[...page].astro form: ported PageHero, 260px sticky category side panel, 3-col sf-tp-card grid reusing the 10a card components - post-like listings (/news/, /post/) get the news/[...page].astro form: 1fr+310px two-col layout, horizontal news rows (290px media + tag/ title/summary/Read more), three aside boxes (Latest/Popular/count) - pagination is the cms Pagination window (first/last + current-2..+2, ellipsis collapse) with arrow-left/arrow-right SVG icons and builder-consistent URLs (page 1 = section root) - platform filter contract kept: .pro-filter-panel stays the immediate previous sibling of .pro-filter-scope (pro.js previousElementSibling lookup); cards/rows carry .pro-card + data-brand/data-category - empty sections hide filters/grid/pagination behind a friendly empty state (noindex scaffold pages) Test: test_trade_pro_listing_layout_structure drives a real build with 105 imported products + 3 posts, asserting both branches, static pagination on /categories/pumps/ pages 1-2, contract adjacency, and no bare arrow glyphs. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,109 @@ def test_trade_pro_product_layout_structure(factory):
|
||||
assert len(soup.find_all('h1')) == 1
|
||||
|
||||
|
||||
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=100,
|
||||
# 两页真实静态分页路由),比 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')
|
||||
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')) == 100
|
||||
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')
|
||||
nav2 = s2.select_one('nav.sf-tp-pagination')
|
||||
assert len(s2.select('.sf-tp-card-grid--listing .sf-tp-card')) == 5
|
||||
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
|
||||
assert nav2.select_one('a[rel="next"]') is None
|
||||
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 'nav.sf-tp-pagination' not in cases
|
||||
|
||||
|
||||
def test_frontend_entries_include_trade_pro():
|
||||
from pathlib import Path
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
|
||||
Reference in New Issue
Block a user