Phase 2 评审修复: P0 min_score+&q=500 (ORM→list 顺序) + P1 场景分页(20/page) + P2 文档同步 + 36 tests

This commit is contained in:
ila
2026-07-06 23:04:55 +08:00
parent cff3c64c19
commit 714d218eb2
6 changed files with 218 additions and 6 deletions
+11 -5
View File
@@ -31,12 +31,18 @@ class ScenarioPage(Page):
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["projects"] = (
projects = (
SkeletonProjectPage.objects.live()
.filter(scenarios=self)
.prefetch_related("languages", "scenarios")
.order_by("-first_published_at")
)
paginator = Paginator(projects, 20)
page = request.GET.get("page", "1")
try:
context["projects"] = paginator.page(int(page))
except (ValueError, EmptyPage):
context["projects"] = paginator.page(1)
return context
class Meta:
@@ -148,6 +154,10 @@ class ProjectIndexPage(Page):
projects = projects.filter(frameworks__slug=framework)
if database:
projects = projects.filter(databases__slug=database)
if q:
projects = projects.filter(
Q(title__icontains=q) | Q(summary__icontains=q)
)
try:
score_val = int(min_score)
if 0 <= score_val <= 30:
@@ -156,10 +166,6 @@ class ProjectIndexPage(Page):
]
except (ValueError, TypeError):
pass
if q:
projects = projects.filter(
Q(title__icontains=q) | Q(summary__icontains=q)
)
paginator = Paginator(projects, 20)
page = request.GET.get("page", "1")
+12
View File
@@ -22,6 +22,18 @@
</a>
{% endfor %}
</div>
{% if projects.paginator.num_pages > 1 %}
<nav class="pagination">
{% if projects.has_previous %}
<a href="?page={{ projects.previous_page_number }}">&laquo; Prev</a>
{% endif %}
<span class="current">Page {{ projects.number }} of {{ projects.paginator.num_pages }}</span>
{% if projects.has_next %}
<a href="?page={{ projects.next_page_number }}">Next &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
{% include "includes/empty_state.html" with message="No projects found in this scenario." %}
{% endif %}
+55
View File
@@ -75,6 +75,46 @@ class ScenarioPageTests(PageTreeMixin, TestCase):
self.assertContains(response, "Nothing here yet")
class ScenarioPaginationTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Python", slug="python")
for i in range(21):
project = SkeletonProjectPage(
title=f"Pagination Project {i+1}",
slug=f"pagination-project-{i+1}",
summary=f"Project {i+1} for pagination test",
github_url="https://github.com/test/pagination",
maturity="stable",
recommended_for="Testing",
structure_score=3, docs_score=3, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
cls.project_index.add_child(instance=project)
project.languages.add(cls.lang)
project.scenarios.add(cls.scenario)
project.save()
def test_first_page_has_20_items(self):
resp = self.client.get(self.scenario.url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Pagination Project 1")
self.assertContains(resp, "Pagination Project 20")
self.assertNotContains(resp, "Pagination Project 21")
def test_second_page_has_1_item(self):
resp = self.client.get(f"{self.scenario.url}?page=2")
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, "Pagination Project 1")
self.assertContains(resp, "Pagination Project 21")
def test_invalid_page_falls_back_to_first(self):
resp = self.client.get(f"{self.scenario.url}?page=abc")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Pagination Project 1")
class ProjectFilterTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
@@ -135,6 +175,21 @@ class ProjectFilterTests(PageTreeMixin, TestCase):
resp = self.client.get("/projects/?min_score=abc&page=xyz")
self.assertEqual(resp.status_code, 200)
def test_combined_filters_return_200(self):
resp = self.client.get(
"/projects/?language=python&framework=django&min_score=18&q=Django"
)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_combined_filters_no_results_returns_empty_state(self):
resp = self.client.get(
"/projects/?language=go&framework=django&min_score=18"
)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "No projects match")
class SkeletonProjectDetailTests(PageTreeMixin, TestCase):
@classmethod
+1 -1
View File
@@ -16,7 +16,7 @@
- git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略)
- 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点
- 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建
- 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过
- 测试:31 个测试(`home/tests.py` + `core/tests.py`),全部通过;`manage.py test` 可运行
- 数据:3 个场景、5 个骨架项目、2 篇文章已录入(`core/management/commands/seed_data.py`)
- 当前 blocker:无;下一步执行 `T-301`(Phase 3 SEO 与上线准备)
+122
View File
@@ -0,0 +1,122 @@
# Phase 2 前台 MVP 评审
评审日期:2026-07-06
评审角色:全栈开发工程师
评审对象:DeepSeek 完成的 Phase 2(T-201 至 T-206)
## 结论
Phase 2 **暂不达标**。
基础页面、首页、项目详情、文章详情等主要页面已经可访问,自动化测试也通过;但筛选搜索的组合参数存在 500 错误,且场景页没有实现任务详单要求的分页。因此不能把 Phase 2 视为完整验收通过。
## 阻断问题
### P0:项目列表 `min_score + q` 组合筛选会 500
- 影响任务:T-204
- 位置:`core/models.py:151` 至 `core/models.py:162`
- 现象:`min_score` 合法时,代码把 `projects` 从 QuerySet 转成 list;后续如果同时存在 `q`,继续调用 `projects.filter(...)`,触发 `AttributeError: 'list' object has no attribute 'filter'`。
- 复现:
```bash
.venv/bin/python3.12.exe manage.py shell -c "from django.test import Client; c=Client(raise_request_exception=False); print(c.get('/projects/?min_score=18&q=Django').status_code)"
```
实际结果:`500`
任务契约要求 `language` / `framework` / `database` / `min_score` / `q` / `page` 多参数同时出现时按 AND 组合,见 `docs/04-architecture.md:157` 至 `docs/04-architecture.md:161`。当前实现只覆盖了单项筛选测试,没有覆盖组合筛选,见 `core/tests.py:119` 至 `core/tests.py:136`。
建议修复方向:
- 保持筛选链路的数据类型一致。
- 可以先用 ORM 对 `q`、language、framework、database 过滤,再对 `min_score` 做 Python 层过滤;也可以用 annotation 计算总分后在 ORM 层过滤。
- 增加组合筛选测试,例如 `/projects/?language=python&framework=django&min_score=18&q=Django`。
### P1:场景页未实现分页
- 影响任务:T-203
- 位置:`core/models.py:32` 至 `core/models.py:40`,`core/templates/core/scenario_page.html:7` 至 `core/templates/core/scenario_page.html:27`
- 现象:场景页直接把全部匹配项目放进 `context["projects"]`,模板也没有分页控件。
- 任务详单要求:场景页只列 live 项目,分页每页 20,空场景渲染空状态。
当前只满足 live 项目和空状态;分页缺失。已有测试只验证有项目和空状态,未验证超过 20 条时分页行为,见 `core/tests.py:40` 至 `core/tests.py:75`。
建议修复方向:
- 在 `ScenarioPage.get_context()` 中使用 `Paginator(projects, 20)`。
- 支持非法 `page` 回退到第一页,行为和项目列表页一致。
- 在 `scenario_page.html` 复用项目列表页的分页 UI。
- 增加 21 个项目的场景页分页测试。
## 非阻断问题
### P2:`docs/current-state.md` 测试数量已过期
- 位置:`docs/current-state.md:19`
- 现象:文档写的是 `home/tests.py` 5 个、`core/tests.py` 17 个;实际 `manage.py test` 输出为 31 个测试。
- 影响:不影响运行,但会误导后续 agent 判断当前状态。
建议修复:更新当前快照里的测试数量。
### P2:文章列表实现偏薄,但可接受
- 影响任务:T-206
- 位置:`core/templates/core/article_index_page.html:7` 至 `core/templates/core/article_index_page.html:13`
- 现状:文章列表通过 `page.get_children.live` 直接渲染标题链接,没有自定义 `get_context()`,也没有分页。
- 评估:T-206 只要求文章可访问、文章详情可链接项目详情;当前能满足最低验收。后续内容量增长时建议补排序、摘要和分页。
## 逐任务验收
| 任务 | 结论 | 说明 |
| --- | --- | --- |
| T-201 基础页面框架 | 达标 | `base.html` 有 viewport meta,导航、页脚、基础 CSS 已实现,容器使用 `max-width` 和响应式宽度。 |
| T-202 首页 | 达标 | 首页展示场景入口、推荐骨架、最新文章,内容来自数据库上下文。 |
| T-203 场景页和项目列表页 | 未达标 | 项目列表页有分页和空状态;场景页有项目和空状态,但缺少每页 20 的分页。 |
| T-204 筛选与基础搜索 | 未达标 | 单项筛选可用,但 `min_score` 与 `q` 组合请求返回 500,不符合 AND 组合契约。 |
| T-205 骨架详情页 | 达标 | 展示详情字段、评分、推荐理由,`is_sponsored=True` 时渲染 Sponsored 标识。 |
| T-206 文章列表和详情 | 达标 | `/articles/` 和文章详情可访问,文章详情可链接关联项目;实现较薄但满足最低验收。 |
## 验证记录
已执行:
```bash
.venv/bin/python3.12.exe manage.py check
.venv/bin/python3.12.exe manage.py makemigrations --check --dry-run
.venv/bin/python3.12.exe manage.py test
.venv/bin/python3.12.exe manage.py shell -c "from core.models import ScenarioPage,SkeletonProjectPage,ArticlePage; print(ScenarioPage.objects.live().count(), SkeletonProjectPage.objects.live().count(), ArticlePage.objects.live().count())"
.venv/bin/python3.12.exe manage.py shell -c "from django.test import Client; c=Client(raise_request_exception=False); paths=['/','/projects/','/articles/','/projects/?language=python','/projects/?min_score=18','/projects/?q=Django','/projects/?min_score=18&q=Django']; [print(p, c.get(p).status_code) for p in paths]"
```
结果摘要:
- `manage.py check`:0 error,9 个 treebeard/Wagtail 兼容 warning。
- `makemigrations --check --dry-run`:No changes detected。
- `manage.py test`:31 tests passed。
- seed 数据:3 个 live 场景、5 个 live 项目、2 篇 live 文章。
- URL 补测:
- `/`:200
- `/projects/`:200
- `/articles/`:200
- `/projects/?language=python`:200
- `/projects/?min_score=18`:200
- `/projects/?q=Django`:200
- `/projects/?min_score=18&q=Django`:500
## 修复验收建议
修复后至少补充并通过以下测试:
1. 项目列表组合筛选:`language + framework + database + min_score + q` 同时存在时返回 200,并只展示满足全部条件的项目。
2. 项目列表组合筛选无结果时返回 200,并展示空状态。
3. 场景页 21 个 live 项目时第一页展示 20 个,第二页展示剩余项目。
4. 场景页非法 `page` 参数返回第一页,不报错。
修复完成后重新运行:
```bash
.venv/bin/python3.12.exe manage.py check
.venv/bin/python3.12.exe manage.py makemigrations --check --dry-run
.venv/bin/python3.12.exe manage.py test
```
+17
View File
@@ -267,3 +267,20 @@
- 阻塞:无。
- 下一步:T-301 补 SEO 基础(Phase 3)。
## 2026-07-06 Phase 2 评审修复(P0 min_score+500 + P1 场景分页 + P2 文档同步)
- 状态:DONE
- 变更:
- **P0**:`core/models.py` ProjectIndexPage.get_context() 调换 `q` 与 `min_score` 的执行顺序——ORM 筛选(`q`/language/framework/database)先执行,最后做 Python 层 `min_score` 列表过滤,避免 QuerySet→list 后 `.filter()` 崩溃
- **P1**:`core/models.py` ScenarioPage.get_context() 新增 `Paginator(projects, 20)`;`scenario_page.html` 复用分页 UI;新增 3 个分页测试
- **P2**:`docs/current-state.md` 测试数量同步为 31
- 新增测试:`test_combined_filters_return_200`、`test_combined_filters_no_results_returns_empty_state`、`test_first_page_has_20_items`、`test_second_page_has_1_item`、`test_invalid_page_falls_back_to_first`
- 验证:
- `manage.py test core home`:36 tests passed
- `min_score=18&q=Django` 返回 200 ✅
- `min_score=18&page=abc` 返回 200 ✅
- `manage.py check`:0 errors
- `makemigrations --check --dry-run`:No changes
- 阻塞:无。
- 下一步:T-301 补 SEO 基础(Phase 3)。