feat(review): 优化审核结果提交和重复数据合并逻辑
Showing
3 changed files
with
150 additions
and
28 deletions
| ... | @@ -646,6 +646,7 @@ | ... | @@ -646,6 +646,7 @@ |
| 646 | <option value="unsure">待确认</option> | 646 | <option value="unsure">待确认</option> |
| 647 | <option value="reviewed">已审核</option> | 647 | <option value="reviewed">已审核</option> |
| 648 | <option value="submitted">已提交</option> | 648 | <option value="submitted">已提交</option> |
| 649 | <option value="deleted">已删除</option> | ||
| 649 | <option value="">全部</option> | 650 | <option value="">全部</option> |
| 650 | <option value="l1_hit">L1 命中</option> | 651 | <option value="l1_hit">L1 命中</option> |
| 651 | <option value="conflict">L1/L2 冲突</option> | 652 | <option value="conflict">L1/L2 冲突</option> |
| ... | @@ -661,7 +662,7 @@ | ... | @@ -661,7 +662,7 @@ |
| 661 | <option value="200">200</option> | 662 | <option value="200">200</option> |
| 662 | </select> | 663 | </select> |
| 663 | <button id="exportBtn">导出标注</button> | 664 | <button id="exportBtn">导出标注</button> |
| 664 | <button id="importReviewedBtn">入库审核通过</button> | 665 | <button id="importReviewedBtn">提交审核结果</button> |
| 665 | <div class="toolbar-right"> | 666 | <div class="toolbar-right"> |
| 666 | <span>审核人:</span><span id="reviewerDisplay"></span> | 667 | <span>审核人:</span><span id="reviewerDisplay"></span> |
| 667 | <button id="logoutBtn" class="secondary">登出</button> | 668 | <button id="logoutBtn" class="secondary">登出</button> |
| ... | @@ -1002,7 +1003,7 @@ | ... | @@ -1002,7 +1003,7 @@ |
| 1002 | group.review_claim_expires_at = record.review_claim_expires_at || ''; | 1003 | group.review_claim_expires_at = record.review_claim_expires_at || ''; |
| 1003 | group.claimed_by_me = group.review_claimed_by === state.reviewer; | 1004 | group.claimed_by_me = group.review_claimed_by === state.reviewer; |
| 1004 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); | 1005 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); |
| 1005 | group.submitted = group.reviewed && record.staging_status === 'imported'; | 1006 | group.submitted = group.reviewed && ['imported', 'skipped'].includes(record.staging_status); |
| 1006 | } | 1007 | } |
| 1007 | 1008 | ||
| 1008 | function currentQuery() { | 1009 | function currentQuery() { |
| ... | @@ -1059,12 +1060,12 @@ | ... | @@ -1059,12 +1060,12 @@ |
| 1059 | metric('待审核', row.pending_review_count || '0'), | 1060 | metric('待审核', row.pending_review_count || '0'), |
| 1060 | metric('已审核', row.reviewed_count || '0'), | 1061 | metric('已审核', row.reviewed_count || '0'), |
| 1061 | metric('已提交', row.submitted_count || '0'), | 1062 | metric('已提交', row.submitted_count || '0'), |
| 1062 | metric('确认入库', row.approved_count || '0'), | 1063 | metric('确认不重复', row.approved_count || '0'), |
| 1063 | metric('确认不入库', row.rejected_count || '0'), | 1064 | metric('确认重复', row.rejected_count || '0'), |
| 1064 | metric('已删除', row.deleted_count || '0'), | 1065 | metric('已删除', row.deleted_count || '0'), |
| 1065 | metric('new', row.new_count || '-'), | 1066 | metric('新歌', row.new_count || '-'), |
| 1066 | metric('merge', row.duplicate_count || '0'), | 1067 | metric('重复', row.duplicate_count || '0'), |
| 1067 | metric('skip', row.skip_count || '0') | 1068 | metric('已跳过', row.skip_count || '0') |
| 1068 | ].join(''); | 1069 | ].join(''); |
| 1069 | } | 1070 | } |
| 1070 | 1071 | ||
| ... | @@ -1182,20 +1183,21 @@ | ... | @@ -1182,20 +1183,21 @@ |
| 1182 | </div>`; | 1183 | </div>`; |
| 1183 | } | 1184 | } |
| 1184 | 1185 | ||
| 1185 | async function updateStagingReview(query, decision, note) { | 1186 | async function updateStagingReview(query, decision, note, candidateId = '') { |
| 1186 | return reviewPostJSON('/api/staging-review', { | 1187 | return reviewPostJSON('/api/staging-review', { |
| 1187 | staging_id: String(query.staging_id), | 1188 | staging_id: String(query.staging_id), |
| 1188 | expected_version: Number(query.review_version || 0), | 1189 | expected_version: Number(query.review_version || 0), |
| 1189 | client_token: state.clientToken, | 1190 | client_token: state.clientToken, |
| 1190 | reviewer: state.reviewer, | 1191 | reviewer: state.reviewer, |
| 1191 | decision, | 1192 | decision, |
| 1192 | note | 1193 | note, |
| 1194 | candidate_id: candidateId | ||
| 1193 | }); | 1195 | }); |
| 1194 | } | 1196 | } |
| 1195 | 1197 | ||
| 1196 | const _DB_REVIEW_LABELS = { | 1198 | const _DB_REVIEW_LABELS = { |
| 1197 | approved_import: '确认入库(待入库)', | 1199 | approved_import: '确认不重复(待入库)', |
| 1198 | rejected_duplicate: '确认不入库', | 1200 | rejected_duplicate: '确认重复(待合并作者)', |
| 1199 | unsure: '待确认', | 1201 | unsure: '待确认', |
| 1200 | deleted: '已删除', | 1202 | deleted: '已删除', |
| 1201 | }; | 1203 | }; |
| ... | @@ -1293,8 +1295,9 @@ | ... | @@ -1293,8 +1295,9 @@ |
| 1293 | const needsManualReview = rowDecision(query) === 'review'; | 1295 | const needsManualReview = rowDecision(query) === 'review'; |
| 1294 | const hasFinalReview = Boolean(query.review_status) && | 1296 | const hasFinalReview = Boolean(query.review_status) && |
| 1295 | !['pending', 'not_required', 'unsure'].includes(query.review_status); | 1297 | !['pending', 'not_required', 'unsure'].includes(query.review_status); |
| 1296 | const canUndoReview = hasFinalReview && query.staging_status !== 'imported'; | 1298 | const isSubmitted = ['imported', 'skipped'].includes(query.staging_status); |
| 1297 | const canDeleteRecord = query.staging_status !== 'imported'; | 1299 | const canUndoReview = hasFinalReview && query.staging_status === 'staged'; |
| 1300 | const canDeleteRecord = !['imported', 'skipped'].includes(query.staging_status); | ||
| 1298 | 1301 | ||
| 1299 | // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) | 1302 | // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) |
| 1300 | els.stickyInfo.innerHTML = ` | 1303 | els.stickyInfo.innerHTML = ` |
| ... | @@ -1339,7 +1342,7 @@ | ... | @@ -1339,7 +1342,7 @@ |
| 1339 | <div class="section-head"> | 1342 | <div class="section-head"> |
| 1340 | <h2 class="section-title">人工标注</h2> | 1343 | <h2 class="section-title">人工标注</h2> |
| 1341 | ${hasFinalReview | 1344 | ${hasFinalReview |
| 1342 | ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : query.staging_status === 'imported' ? '已提交' : '已审核'}</span>` | 1345 | ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : isSubmitted ? '已提交' : '已审核'}</span>` |
| 1343 | : ''} | 1346 | : ''} |
| 1344 | </div> | 1347 | </div> |
| 1345 | <div class="section-body"> | 1348 | <div class="section-body"> |
| ... | @@ -1347,7 +1350,9 @@ | ... | @@ -1347,7 +1350,9 @@ |
| 1347 | <div class="review-bar" style="align-items:center"> | 1350 | <div class="review-bar" style="align-items:center"> |
| 1348 | <span style="flex:1"> | 1351 | <span style="flex:1"> |
| 1349 | <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' | 1352 | <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' |
| 1350 | ? '确认入库(已提交)' | 1353 | ? '确认不重复(已入库)' |
| 1354 | : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped' | ||
| 1355 | ? '确认重复(作者已合并)' | ||
| 1351 | : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> | 1356 | : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> |
| 1352 | ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} | 1357 | ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} |
| 1353 | ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} | 1358 | ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} |
| ... | @@ -1359,7 +1364,7 @@ | ... | @@ -1359,7 +1364,7 @@ |
| 1359 | <div class="review-bar"> | 1364 | <div class="review-bar"> |
| 1360 | ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` | 1365 | ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` |
| 1361 | <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> | 1366 | <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> |
| 1362 | ${value === 'duplicate' ? '确认不入库' : value === 'not_duplicate' ? '确认入库' : '待确认'} | 1367 | ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} |
| 1363 | </button>`).join('')} | 1368 | </button>`).join('')} |
| 1364 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> | 1369 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> |
| 1365 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> | 1370 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> |
| ... | @@ -1411,7 +1416,9 @@ | ... | @@ -1411,7 +1416,9 @@ |
| 1411 | renderDetail(); | 1416 | renderDetail(); |
| 1412 | return; | 1417 | return; |
| 1413 | } | 1418 | } |
| 1414 | const payload = await updateStagingReview(query, dbDecision, note); | 1419 | const payload = await updateStagingReview( |
| 1420 | query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : '' | ||
| 1421 | ); | ||
| 1415 | for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); | 1422 | for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); |
| 1416 | // 刷新统计和加载列表并行 | 1423 | // 刷新统计和加载列表并行 |
| 1417 | await Promise.all([reloadSummary(), loadGroups()]); | 1424 | await Promise.all([reloadSummary(), loadGroups()]); |
| ... | @@ -1530,12 +1537,12 @@ | ... | @@ -1530,12 +1537,12 @@ |
| 1530 | alert('历史报表仅供查看,请在 staging-db 中执行入库。'); | 1537 | alert('历史报表仅供查看,请在 staging-db 中执行入库。'); |
| 1531 | return; | 1538 | return; |
| 1532 | } | 1539 | } |
| 1533 | if (!confirm('确认入库数据库中所有"确认入库(待入库)"样本?')) return; | 1540 | if (!confirm('确认提交所有审核结果?“确认不重复”将入库,“确认重复”将把词/曲作者增量合并到已有记录。')) return; |
| 1534 | els.importReviewedBtn.disabled = true; | 1541 | els.importReviewedBtn.disabled = true; |
| 1535 | els.importReviewedBtn.textContent = '入库中...'; | 1542 | els.importReviewedBtn.textContent = '入库中...'; |
| 1536 | try { | 1543 | try { |
| 1537 | const payload = await reviewPostJSON('/api/import-reviewed'); | 1544 | const payload = await reviewPostJSON('/api/import-reviewed'); |
| 1538 | alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`); | 1545 | alert(`提交完成:不重复入库 ${payload.inserted_count} 条,重复样本 ${payload.duplicate_count || 0} 条,其中作者有增量 ${payload.author_merged_count || 0} 条。结果文件:${payload.review_csv}`); |
| 1539 | // 刷新统计和加载列表并行 | 1546 | // 刷新统计和加载列表并行 |
| 1540 | await Promise.all([reloadSummary(), loadGroups()]); | 1547 | await Promise.all([reloadSummary(), loadGroups()]); |
| 1541 | renderAll(); | 1548 | renderAll(); |
| ... | @@ -1543,14 +1550,17 @@ | ... | @@ -1543,14 +1550,17 @@ |
| 1543 | if (err.status === 409 && err.payload?.code === 'import_conflict') { | 1550 | if (err.status === 409 && err.payload?.code === 'import_conflict') { |
| 1544 | const conflicts = err.payload.conflicts || []; | 1551 | const conflicts = err.payload.conflicts || []; |
| 1545 | const details = conflicts.slice(0, 20).map((row, index) => { | 1552 | const details = conflicts.slice(0, 20).map((row, index) => { |
| 1546 | const types = (row.conflict_types || []).map(type => | 1553 | const typeLabels = { |
| 1547 | type === 'primary_key' ? '主键 ID 冲突' : '来源键冲突' | 1554 | primary_key: '主键 ID 冲突', |
| 1548 | ).join(' + ') || '唯一键冲突'; | 1555 | source_key: '来源键冲突', |
| 1556 | duplicate_target_missing: '未找到已入库的重复目标' | ||
| 1557 | }; | ||
| 1558 | const types = (row.conflict_types || []).map(type => typeLabels[type] || type).join(' + ') || '数据冲突'; | ||
| 1549 | const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] | 1559 | const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] |
| 1550 | .filter(value => value !== null && value !== undefined) | 1560 | .filter(value => value !== null && value !== undefined) |
| 1551 | .filter((value, i, values) => values.indexOf(value) === i) | 1561 | .filter((value, i, values) => values.indexOf(value) === i) |
| 1552 | .join('/'); | 1562 | .join('/'); |
| 1553 | return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; | 1563 | return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | 重复候选=${row.matched_song_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; |
| 1554 | }); | 1564 | }); |
| 1555 | if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); | 1565 | if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); |
| 1556 | alert([ | 1566 | alert([ |
| ... | @@ -1565,7 +1575,7 @@ | ... | @@ -1565,7 +1575,7 @@ |
| 1565 | } | 1575 | } |
| 1566 | } finally { | 1576 | } finally { |
| 1567 | els.importReviewedBtn.disabled = false; | 1577 | els.importReviewedBtn.disabled = false; |
| 1568 | els.importReviewedBtn.textContent = '入库审核通过'; | 1578 | els.importReviewedBtn.textContent = '提交审核结果'; |
| 1569 | } | 1579 | } |
| 1570 | } | 1580 | } |
| 1571 | 1581 | ||
| ... | @@ -1659,7 +1669,7 @@ | ... | @@ -1659,7 +1669,7 @@ |
| 1659 | group.review_claim_expires_at = remote.review_claim_expires_at || ''; | 1669 | group.review_claim_expires_at = remote.review_claim_expires_at || ''; |
| 1660 | group.claimed_by_me = Boolean(remote.claimed_by_me); | 1670 | group.claimed_by_me = Boolean(remote.claimed_by_me); |
| 1661 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); | 1671 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); |
| 1662 | group.submitted = group.reviewed && remote.staging_status === 'imported'; | 1672 | group.submitted = group.reviewed && ['imported', 'skipped'].includes(remote.staging_status); |
| 1663 | } | 1673 | } |
| 1664 | const query = currentQuery(); | 1674 | const query = currentQuery(); |
| 1665 | const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; | 1675 | const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; | ... | ... |
This diff is collapsed.
Click to expand it.
| ... | @@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): | ... | @@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): |
| 96 | assert not conn.committed | 96 | assert not conn.committed |
| 97 | 97 | ||
| 98 | 98 | ||
| 99 | def test_rejected_duplicate_persists_the_selected_candidate(): | ||
| 100 | snapshot = { | ||
| 101 | "staging_id": 42, | ||
| 102 | "matched_song_id": None, | ||
| 103 | "recalled_candidates": '[{"id":"existing-song"}]', | ||
| 104 | "biz_review_status": "rejected_duplicate", | ||
| 105 | "review_version": 8, | ||
| 106 | } | ||
| 107 | cursor = FakeCursor(update_rowcount=1, snapshot=snapshot) | ||
| 108 | conn = FakeConnection(cursor) | ||
| 109 | |||
| 110 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 111 | dashboard._update_staging_review( | ||
| 112 | 42, "rejected_duplicate", "same", "alice", 7, "browser-token", "existing-song" | ||
| 113 | ) | ||
| 114 | |||
| 115 | update_sql, params = cursor.executions[1] | ||
| 116 | assert "matched_song_id = %s" in update_sql | ||
| 117 | assert params[:3] == ["rejected_duplicate", "alice", "existing-song"] | ||
| 118 | assert conn.committed | ||
| 119 | |||
| 120 | |||
| 121 | def test_rejected_duplicate_rejects_a_candidate_outside_recall_results(): | ||
| 122 | cursor = FakeCursor( | ||
| 123 | update_rowcount=1, | ||
| 124 | snapshot={"staging_id": 42, "matched_song_id": "candidate-a", "recalled_candidates": "[]"}, | ||
| 125 | ) | ||
| 126 | conn = FakeConnection(cursor) | ||
| 127 | |||
| 128 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 129 | with pytest.raises(ValueError, match="不在当前召回结果"): | ||
| 130 | dashboard._update_staging_review( | ||
| 131 | 42, "rejected_duplicate", "", "alice", 7, "browser-token", "candidate-b" | ||
| 132 | ) | ||
| 133 | |||
| 134 | assert conn.rolled_back | ||
| 135 | assert not any(sql.lstrip().startswith("UPDATE") for sql, _params in cursor.executions) | ||
| 136 | |||
| 137 | |||
| 99 | def test_pending_review_restores_a_deleted_staging_row(): | 138 | def test_pending_review_restores_a_deleted_staging_row(): |
| 100 | snapshot = { | 139 | snapshot = { |
| 101 | "staging_id": 42, | 140 | "staging_id": 42, |
| ... | @@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): | ... | @@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): |
| 185 | def __init__(self) -> None: | 224 | def __init__(self) -> None: |
| 186 | super().__init__(update_rowcount=1, snapshot={}) | 225 | super().__init__(update_rowcount=1, snapshot={}) |
| 187 | self.result_sets = [ | 226 | self.result_sets = [ |
| 188 | [{"staging_id": 42, "source_song_id": "song-1"}], | 227 | [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}], |
| 189 | [], | 228 | [], |
| 190 | [{"source_song_id": "song-1", "target_id": 9001}], | 229 | [{"source_song_id": "song-1", "target_id": 9001}], |
| 191 | ] | 230 | ] |
| ... | @@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): | ... | @@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): |
| 217 | def test_import_conflict_rolls_back_and_exposes_the_staging_row(): | 256 | def test_import_conflict_rolls_back_and_exposes_the_staging_row(): |
| 218 | cursor = ImportCursor() | 257 | cursor = ImportCursor() |
| 219 | cursor.result_sets = [ | 258 | cursor.result_sets = [ |
| 220 | [{"staging_id": 42, "source_song_id": "song-1"}], | 259 | [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}], |
| 221 | [{ | 260 | [{ |
| 222 | "staging_id": 42, | 261 | "staging_id": 42, |
| 223 | "source_song_id": "song-1", | 262 | "source_song_id": "song-1", |
| ... | @@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): | ... | @@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): |
| 241 | assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) | 280 | assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) |
| 242 | 281 | ||
| 243 | 282 | ||
| 283 | class DuplicateImportCursor: | ||
| 284 | def __init__(self) -> None: | ||
| 285 | self.rowcount = 0 | ||
| 286 | self.executions: list[tuple[str, object]] = [] | ||
| 287 | self.current = None | ||
| 288 | |||
| 289 | def __enter__(self): | ||
| 290 | return self | ||
| 291 | |||
| 292 | def __exit__(self, *_args): | ||
| 293 | return False | ||
| 294 | |||
| 295 | def execute(self, sql, params=None): | ||
| 296 | self.executions.append((sql, params)) | ||
| 297 | compact = " ".join(sql.split()) | ||
| 298 | self.rowcount = 0 | ||
| 299 | if "SELECT s.staging_id" in compact and "FOR UPDATE" in compact: | ||
| 300 | self.current = [{ | ||
| 301 | "staging_id": 42, | ||
| 302 | "source_song_id": "new-song", | ||
| 303 | "source_table_name": "hk_song_platform", | ||
| 304 | "name": "same song", | ||
| 305 | "lyricist": "Alice/Bob", | ||
| 306 | "composer": "Composer B", | ||
| 307 | "biz_review_status": "rejected_duplicate", | ||
| 308 | "matched_song_id": "existing-song", | ||
| 309 | }] | ||
| 310 | elif "SELECT id FROM" in compact and "source_table_name = %s" in compact: | ||
| 311 | self.current = {"id": 9001} | ||
| 312 | elif "SELECT id, lyricist, composer" in compact: | ||
| 313 | self.current = {"id": 9001, "lyricist": "Alice", "composer": "Composer A"} | ||
| 314 | elif compact.startswith("UPDATE"): | ||
| 315 | self.current = None | ||
| 316 | self.rowcount = 1 | ||
| 317 | else: | ||
| 318 | raise AssertionError(f"unexpected SQL: {compact}") | ||
| 319 | |||
| 320 | def fetchall(self): | ||
| 321 | return self.current or [] | ||
| 322 | |||
| 323 | def fetchone(self): | ||
| 324 | return self.current | ||
| 325 | |||
| 326 | |||
| 327 | def test_confirmed_duplicate_merges_authors_without_inserting_a_new_song(): | ||
| 328 | cursor = DuplicateImportCursor() | ||
| 329 | conn = FakeConnection(cursor) | ||
| 330 | |||
| 331 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 332 | result = dashboard._import_approved_staging() | ||
| 333 | |||
| 334 | sql_params = [(" ".join(sql.split()), params) for sql, params in cursor.executions] | ||
| 335 | assert not any(sql.startswith("INSERT") for sql, _params in sql_params) | ||
| 336 | assert any("SET lyricist = %s" in sql and params == ("Alice、Bob", 9001) for sql, params in sql_params) | ||
| 337 | assert any("SET composer = %s" in sql and params == ("Composer A、Composer B", 9001) for sql, params in sql_params) | ||
| 338 | assert any("SET staging_status = 'skipped'" in sql for sql, _params in sql_params) | ||
| 339 | assert result["duplicate_count"] == 1 | ||
| 340 | assert result["author_merged_count"] == 1 | ||
| 341 | assert result["inserted_count"] == 0 | ||
| 342 | assert result["duplicate_source_ids"] == ["new-song"] | ||
| 343 | assert conn.committed | ||
| 344 | |||
| 345 | |||
| 244 | def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): | 346 | def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): |
| 245 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | 347 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") |
| 246 | start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") | 348 | start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") |
| ... | @@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): | ... | @@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): |
| 274 | assert row["staging_status"] == "imported" | 376 | assert row["staging_status"] == "imported" |
| 275 | assert '<option value="reviewed">已审核</option>' in html | 377 | assert '<option value="reviewed">已审核</option>' in html |
| 276 | assert '<option value="submitted">已提交</option>' in html | 378 | assert '<option value="submitted">已提交</option>' in html |
| 277 | assert "hasFinalReview && query.staging_status !== 'imported'" in html | 379 | assert "hasFinalReview && query.staging_status === 'staged'" in html |
| 380 | |||
| 381 | |||
| 382 | def test_dashboard_has_a_dedicated_deleted_filter(): | ||
| 383 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | ||
| 384 | |||
| 385 | assert '<option value="deleted">已删除</option>' in html | ||
| 386 | source = Path("serve_l2_dashboard.py").read_text(encoding="utf-8") | ||
| 387 | assert "elif decision == 'deleted':" in source | ||
| 388 | assert "s.biz_review_status = 'deleted'" in source | ||
| 389 | assert "s.staging_status = 'deleted'" in source | ||
| 278 | 390 | ||
| 279 | 391 | ||
| 280 | def test_review_access_code_issues_and_validates_edit_session(): | 392 | def test_review_access_code_issues_and_validates_edit_session(): | ... | ... |
-
Please register or sign in to post a comment