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:
浅念
2026-10-23 14:06:15 +08:00
parent 1f2e7529a2
commit 9ea8369e2b
4 changed files with 326 additions and 4 deletions
+103
View File
@@ -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