优化批次统计查询并统一评分反馈与复核刷新 #3
@@ -93,6 +93,7 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
|
||||
assert first_page["count"] == 2 and len(first_page["results"]) == 1
|
||||
assert first_page["results"][0]["project_id"] == first.id
|
||||
assert len(first_page["results"][0]["rows"]) == 6
|
||||
assert first_page["results"][0]["rows"][0]["status_label"] == "模型已评分"
|
||||
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
|
||||
assert first_page["citations"][0]["url"] is None
|
||||
assert first_page["citations"][0]["image_url"] is None
|
||||
|
||||
@@ -249,6 +249,38 @@ def test_last_review_updates_batch_and_keeps_zero(sqlite_db):
|
||||
assert sheet["D2"].value == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_count", [0, 20])
|
||||
def test_batch_list_status_statistics_use_constant_query_count(sqlite_db, batch_count):
|
||||
from wisegrade.api import list_batches
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
expected = {}
|
||||
states = ["scored", "reviewed", "needs_review", "failed", "unscorable"]
|
||||
for index in range(batch_count):
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, created["id"])
|
||||
criterion = batch.rubric_snapshot["criteria"][0]["criterion_id"]
|
||||
state = states[index % len(states)]
|
||||
db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion, status=state))
|
||||
expected[batch.id] = {state: 1}
|
||||
db.commit()
|
||||
queries = []
|
||||
def trace(_conn, _cursor, statement, _parameters, _context, _many):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
queries.append(statement)
|
||||
event.listen(db.get_bind(), "before_cursor_execute", trace)
|
||||
try:
|
||||
listed = list_batches(db)
|
||||
finally:
|
||||
event.remove(db.get_bind(), "before_cursor_execute", trace)
|
||||
assert len(queries) <= (2 if batch_count else 1), len(queries)
|
||||
assert [row["id"] for row in listed] == sorted(expected, reverse=True)
|
||||
for row in listed:
|
||||
assert row["status_counts"] == expected[row["id"]]
|
||||
for state in ("needs_review", "failed", "unscorable"):
|
||||
assert row[state] == expected[row["id"]].get(state, 0)
|
||||
|
||||
|
||||
def test_pdf_html_preserves_zero_and_none():
|
||||
batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={},
|
||||
embedding_model_name="test", embedding_model_version="test")
|
||||
@@ -261,6 +293,30 @@ def test_pdf_html_preserves_zero_and_none():
|
||||
assert "实验(0 / 10)" in html and "模型原始分:0" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error, label", [
|
||||
("模型证据未关联该片段的检索目标", "模型输出校验失败"),
|
||||
("模型请求或JSON校验失败(ConnectError:无法连接模型端点),已重试2次", "模型连接失败"),
|
||||
("模型请求或JSON校验失败(模型请求超时,请稍后重试),已重试2次", "模型连接失败"),
|
||||
("模型请求或JSON校验失败(HTTP 429:请求限流),已重试2次", "模型请求失败"),
|
||||
("模型请求或JSON校验失败(ValidationError:模型返回字段不符合格式),已重试2次", "模型输出校验失败"),
|
||||
("后台数据处理异常", "评分失败"),
|
||||
])
|
||||
def test_score_api_and_export_share_precise_failure_labels(sqlite_db, error, label):
|
||||
from wisegrade.exports import project_result
|
||||
db = sqlite_db
|
||||
item = project(db)
|
||||
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||
batch = db.get(ScoringBatch, created["id"])
|
||||
criterion = batch.rubric_snapshot["criteria"][0]
|
||||
db.add(CriterionScore(batch_id=batch.id, project_id=item.id,
|
||||
criterion_id=criterion["criterion_id"], status="failed", error=error))
|
||||
db.commit()
|
||||
result = project_result(db, batch, item)
|
||||
assert result["rows"][0]["status_label"] == label
|
||||
assert f"状态:{label}" in _html_report(result, batch)
|
||||
assert result["rows"][0]["model_score"] is None
|
||||
|
||||
|
||||
def test_public_reindex_uses_saved_pages(sqlite_db):
|
||||
db = sqlite_db
|
||||
doc = document(db, None, "课程资料")
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
|
||||
from .actions import prepare, require_scope
|
||||
from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED
|
||||
from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result
|
||||
from .exports import batch_projects, criterion_status_label, page_type_label, project_result
|
||||
from .knowledge_pages import page_links
|
||||
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage,
|
||||
Project, RubricVersion, ScoringBatch, Student, SubmissionFile)
|
||||
@@ -384,7 +384,7 @@ def scores(db, convo, args, _user):
|
||||
"max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
|
||||
"model_score": row["model_score"], "teacher_score": row["teacher_score"],
|
||||
"final_score": row["final_score"], "status": row["status"],
|
||||
"status_label": STATUS_LABELS.get(row["status"], row["status"]),
|
||||
"status_label": row.get("status_label") or criterion_status_label(row["status"], row.get("error") or ""),
|
||||
"error": str(row["error"] or "")[:300],
|
||||
# Which body targets still lack evidence, so the answer can
|
||||
# explain a review item instead of reporting a score.
|
||||
|
||||
@@ -468,12 +468,15 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)):
|
||||
return services.create_batch(data, db)
|
||||
|
||||
|
||||
def status_count_dict(counts: dict) -> dict:
|
||||
return {"status_counts": counts, "needs_review": counts.get("needs_review", 0),
|
||||
"unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)}
|
||||
|
||||
|
||||
def batch_status_counts(db, batch_id: int) -> dict:
|
||||
rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
|
||||
.where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all()
|
||||
counts = {status: count for status, count in rows}
|
||||
return {"status_counts": counts, "needs_review": counts.get("needs_review", 0),
|
||||
"unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)}
|
||||
return status_count_dict({status: count for status, count in rows})
|
||||
|
||||
def batch_version_metadata(batch: ScoringBatch) -> dict:
|
||||
"""Display the immutable rule/course versions, not their database IDs."""
|
||||
@@ -485,12 +488,20 @@ def batch_version_metadata(batch: ScoringBatch) -> dict:
|
||||
|
||||
@router.get("/scoring-batches")
|
||||
def list_batches(db: Session = Depends(get_db)):
|
||||
batches = db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc())).all()
|
||||
counts_by_batch = {}
|
||||
if batches:
|
||||
rows = db.execute(select(CriterionScore.batch_id, CriterionScore.status, func.count(CriterionScore.id))
|
||||
.where(CriterionScore.batch_id.in_([batch.id for batch in batches]))
|
||||
.group_by(CriterionScore.batch_id, CriterionScore.status)).all()
|
||||
for batch_id, status, count in rows:
|
||||
counts_by_batch.setdefault(batch_id, {})[status] = count
|
||||
return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")},
|
||||
"status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
|
||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
|
||||
**batch_version_metadata(batch),
|
||||
**batch_status_counts(db, batch.id)}
|
||||
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))]
|
||||
**status_count_dict(counts_by_batch.get(batch.id, {}))}
|
||||
for batch in batches]
|
||||
|
||||
|
||||
@router.get("/scoring-batches/{batch_id}/progress")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
@@ -18,7 +19,7 @@ STATUS_LABELS = {
|
||||
"reviewed": "教师已复核",
|
||||
"needs_review": "待教师复核",
|
||||
"unscorable": "材料缺失或解析失败",
|
||||
"failed": "模型调用失败",
|
||||
"failed": "评分失败",
|
||||
}
|
||||
PAGE_TYPE_LABELS = {
|
||||
"cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文",
|
||||
@@ -38,6 +39,21 @@ def page_type_label(value) -> str:
|
||||
return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面")
|
||||
|
||||
|
||||
def criterion_status_label(status: str, error: str = "") -> str:
|
||||
"""One description for API, chat and exported feedback, including old errors."""
|
||||
if status != "failed":
|
||||
return STATUS_LABELS.get(status, status)
|
||||
# The client wraps transport failures in "模型请求或JSON校验失败" too:
|
||||
# inspect the concrete cause before that generic wrapper.
|
||||
if re.search(r"ConnectError|Timeout|Network|无法连接|请求超时", error, re.IGNORECASE):
|
||||
return "模型连接失败"
|
||||
if re.search(r"HTTP\s*\d{3}|未配置.*(?:模型|Key)|未配置或无法连接", error, re.IGNORECASE):
|
||||
return "模型请求失败"
|
||||
if re.search(r"模型证据|target_id|评分项或满分|JSON校验|ValidationError", error):
|
||||
return "模型输出校验失败"
|
||||
return STATUS_LABELS["failed"]
|
||||
|
||||
|
||||
def project_result(db, batch: ScoringBatch, project: Project):
|
||||
scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
||||
CriterionScore.project_id == project.id)).all()
|
||||
@@ -81,6 +97,7 @@ def project_result(db, batch: ScoringBatch, project: Project):
|
||||
"model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None,
|
||||
"missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "",
|
||||
"status": score.status if score else "pending", "error": score.error if score else "",
|
||||
"status_label": criterion_status_label(score.status, score.error or "") if score else STATUS_LABELS["pending"],
|
||||
"evidence_gaps": (score.evidence_gaps or []) if score else [],
|
||||
"evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]})
|
||||
student = db.get(Student, project.student_id)
|
||||
@@ -152,12 +169,12 @@ def _html_report(result, batch):
|
||||
missing = "、".join(row["missing_evidence"] or [])
|
||||
gaps = ";".join(f"{gap.get('target_label')}(检索范围:{'、'.join(page_type_label(t) for t in gap.get('searched_page_types') or []) or '正文'})"
|
||||
for gap in row.get("evidence_gaps") or [])
|
||||
review = "本项缺少正文证据,已转教师复核。" if row["status"] == "needs_review" else ""
|
||||
review = "本项证据尚不足以自动评分,已转教师复核。" if row["status"] == "needs_review" else ""
|
||||
rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>"
|
||||
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(STATUS_LABELS.get(row['status'], row['status']))}</p>"
|
||||
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(criterion_status_label(row['status'], row.get('error') or ''))}</p>"
|
||||
f"<p>模型评语:{esc(row['model_comment'])}</p><p>教师评语:{esc(row['teacher_comment'])}</p>"
|
||||
f"<p>{esc(review)}</p>"
|
||||
f"<p>缺失证据:{esc(missing)}</p><p>缺正文证据的目标:{esc(gaps)}</p>"
|
||||
f"<p>缺失证据:{esc(missing)}</p><p>待核对的证据目标:{esc(gaps)}</p>"
|
||||
f"<p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>"
|
||||
f"<ul>{evidences}</ul></section>")
|
||||
totals_ = result["totals"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 评分反馈与批次列表优化(2026-10-18)
|
||||
|
||||
## 改动
|
||||
|
||||
- `0006387`:批次列表的状态统计改为按批次和状态一次聚合,避免每个批次额外查询。接口字段、排序和无评分时的零计数保持一致。
|
||||
- `ba5a695`:API、聊天和导出反馈共用评分状态名称。先识别具体连接/超时/HTTP错误,再识别通用JSON校验提示,避免把被统一包装的连接错误误标为输出错误;证据不足说明不再断言报告没有正文。
|
||||
- `4fec28c`:网页优先显示API给出的状态名称,并兼容缺少该字段的旧结果。
|
||||
- `aa82523`:增加“刷新评分”;保存教师复核后同步刷新批次状态与评分。刷新保留当前筛选,切换批次或学生时重置筛选;旧学生迟到的响应不会覆盖当前学生结果,教师零分正常保留。
|
||||
- `93bcd2d`:聊天工具对简化评分结果回退到共用分类函数,不要求所有结果提供新增字段。
|
||||
|
||||
分支为 `improve/grading-feedback`,基于上游合并PR #2后的 `7e2cdbd`。每项改动独立提交,单次涉及1–3个文件。
|
||||
|
||||
## 验证
|
||||
|
||||
- 独立SQLite回归:20个批次的列表查询从21次降为2次;空列表为1次。各状态计数与原结果一致。
|
||||
- 真实PostgreSQL只读测量:当前8个批次的列表查询从9次降为2次。这里记录查询次数,不将其等同于延迟提升比例。
|
||||
- Docker独立PostgreSQL/Redis环境运行完整非`integration`后端测试:154 passed,2 deselected。未运行在线模型测试,临时测试服务已停止。
|
||||
- Playwright:22 passed;TypeScript/Vite构建通过。覆盖最后一项复核保存后的批次状态更新、零分、筛选保留、旧学生迟到响应,以及被通用JSON错误包装的连接失败显示。
|
||||
- Docker API/Worker/Web更新并健康;更新前确认无排队和执行中的评分任务。
|
||||
- 实际网页:批次8为22项已评分、0项待处理,批次7为21项已评分、1项待处理;点击刷新后筛选与统计一致。无页面异常、无业务写请求、无模型调用。
|
||||
- 批次8结果仅增加展示用`status_label`字段。排除该新增字段后,更新前后规范化JSON的SHA-256一致,22项评分、评语与证据未改动。
|
||||
|
||||
本轮不重新评分、不改写原PDF、不修改历史批次结果。网页新增按钮只刷新读取数据;教师复核保存行为只在隔离测试中验证。
|
||||
@@ -34,6 +34,7 @@ const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页
|
||||
const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' };
|
||||
const statusLabel = (value: string) => statusLabels[value] || value;
|
||||
const scoreStatusLabel = (row: any) => {
|
||||
if (row.status_label) return row.status_label;
|
||||
if (row.status !== 'failed') return statusLabel(row.status);
|
||||
if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败';
|
||||
if (/ConnectError|Timeout|Network|连接|超时|HTTP/i.test(row.error || '')) return '模型连接失败';
|
||||
@@ -206,6 +207,7 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
|
||||
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
|
||||
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]);
|
||||
const [scoreFilter, setScoreFilter] = useState('all');
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const [sourcesReady, setSourcesReady] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -215,12 +217,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
|
||||
setBatches(batchRows); setProjects(projectRows); setSourcesReady(true);
|
||||
}).catch(error => { if (!cancelled) fail(error); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [refreshVersion]);
|
||||
const selectedBatch = batches.find(item => item.id === batch);
|
||||
const scopedProjects = projectsInBatch(selectedBatch, projects);
|
||||
useEffect(() => { setScoreFilter('all'); }, [batch, project]);
|
||||
useEffect(() => {
|
||||
setResult(undefined); setReview(undefined); setRevisions([]);
|
||||
setScoreFilter('all');
|
||||
if (!sourcesReady || !selectedBatch) return;
|
||||
if (!scopedProjects.some(item => item.id === project)) {
|
||||
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined);
|
||||
@@ -231,7 +233,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
|
||||
.catch(error => { if (!cancelled) fail(error); });
|
||||
return () => { cancelled = true; };
|
||||
}, [batch, project, batches, projects, sourcesReady]);
|
||||
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
|
||||
const refresh = () => setRefreshVersion(value => value + 1);
|
||||
const scoreRows: any[] = result?.rows || [];
|
||||
const count = (status: string) => scoreRows.filter(row => row.status === status).length;
|
||||
const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status);
|
||||
@@ -244,6 +246,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
|
||||
options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />
|
||||
<Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project}
|
||||
disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} />
|
||||
<Button disabled={!sourcesReady || !selectedBatch || !project} onClick={refresh}>刷新评分</Button>
|
||||
</Space>
|
||||
{selectedBatch && <Alert className="top-gap" type="info" showIcon
|
||||
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
|
||||
|
||||
@@ -140,12 +140,14 @@ test('failed scoring shows the network error and retry guidance on the page', as
|
||||
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
|
||||
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
|
||||
rows: [{ criterion: { criterion_id: 'stage1.problem', category: 'stage1', name: '需求分析', max_score: 25 },
|
||||
status: 'failed', error: 'ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理',
|
||||
status: 'failed', status_label: '模型连接失败',
|
||||
error: '模型请求或JSON校验失败(ConnectError:无法连接模型端点,请检查 Docker 网络、DNS 或代理),已重试2次',
|
||||
evidence: [], missing_evidence: [], evidence_gaps: [] }],
|
||||
} }));
|
||||
await page.goto('/?page=scores&batch_id=71&project_id=11');
|
||||
await expect(page.getByText('当前项目有 1 项评分失败', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/ConnectError:无法连接模型端点/)).toBeVisible();
|
||||
await expect(page.getByText('模型连接失败', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -215,6 +217,72 @@ test('score filters isolate actionable rows without hiding zero grades or changi
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test('saving the last review refreshes batch status and keeps the active filter', async ({ page }) => {
|
||||
await setup(page);
|
||||
let saved = false;
|
||||
const saves: any[] = [];
|
||||
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
|
||||
id: 71, course_version_id: 1, project_ids: [11], status: saved ? 'completed' : 'review_required',
|
||||
}] }));
|
||||
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
|
||||
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: saved ? 0 : null }, facts: {},
|
||||
rows: [{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
|
||||
score_id: 501, status: saved ? 'reviewed' : 'needs_review', final_score: saved ? 0 : null,
|
||||
teacher_score: saved ? 0 : null, teacher_comment: saved ? '已核对' : null,
|
||||
evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '需要教师核对' }],
|
||||
} }));
|
||||
await page.route('**/api/criterion-scores/501/review', route => {
|
||||
saves.push(route.request().postDataJSON()); saved = true;
|
||||
return route.fulfill({ json: { id: 501, status: 'reviewed', teacher_score: 0, final_score: 0 } });
|
||||
});
|
||||
await page.goto('/?page=scores&batch_id=71&project_id=11');
|
||||
await choose(page, '评分项筛选', '仅待教师复核(1)');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '查看证据/复核' }).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
await modal.getByRole('spinbutton', { name: '教师评分' }).fill('0');
|
||||
await modal.getByRole('textbox', { name: '教师评语' }).fill('已核对');
|
||||
await modal.getByRole('button', { name: '保存教师复核', exact: true }).click();
|
||||
await expect.poll(() => saves.length).toBe(1);
|
||||
expect(saves[0].score).toBe(0);
|
||||
await expect(page.getByText('批次 #71 · 已完成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('仅待教师复核(0)', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('当前筛选下没有评分项', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('已评分 0 项 · 教师已复核 1 项 · 待处理 0 项', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('manual score refresh ignores late responses for a previously selected student', async ({ page }) => {
|
||||
await setup(page);
|
||||
let held = false, release: (() => void) | undefined;
|
||||
let firstReads = 0;
|
||||
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
|
||||
id: 71, course_version_id: 1, project_ids: [11, 12], status: 'completed',
|
||||
}] }));
|
||||
await page.route('**/api/projects/*/scores?*', async route => {
|
||||
const isFirst = route.request().url().includes('/projects/11/');
|
||||
if (isFirst && ++firstReads > 1) {
|
||||
held = true;
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
}
|
||||
await route.fulfill({ json: {
|
||||
student_no: isFirst ? '9000000001' : '9000000002', name: isFirst ? '学生甲' : '学生乙',
|
||||
topic: isFirst ? '项目甲' : '项目乙', totals: {}, rows: [], facts: {},
|
||||
} });
|
||||
});
|
||||
await page.goto('/?page=scores&batch_id=71&project_id=11');
|
||||
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '刷新评分', exact: true }).click();
|
||||
await expect.poll(() => held).toBe(true);
|
||||
await choose(page, '学生项目', /学生乙/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
|
||||
const late = page.waitForResponse(response => response.url().includes('/projects/11/scores'));
|
||||
release!(); await late;
|
||||
await expect(page.getByText('9000000002 学生乙 · 项目乙', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('9000000001 学生甲 · 项目甲', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
|
||||
const state = await setup(page);
|
||||
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
|
||||
|
||||
Reference in New Issue
Block a user