Files
code-review/app/resolve.test.js
T

291 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
parseBotReviewComment,
groupConversations,
codeWindow,
judgeConversationsResolved,
reconcileConversations,
dropResolvedFindings,
addCarriedFindings,
} from './resolve.js';
const reviewBody = (level, role, problem, suggestion) =>
`**嚴重等級**${level}\n**審查員**${role}\n**問題**${problem}\n**建議**${suggestion}`;
describe('parseBotReviewComment', () => {
it('parses a standard review comment back into a finding', () => {
const f = parseBotReviewComment(reviewBody('🔴 嚴重', 'Assassin', '可能空指標', '加上 null 檢查'));
assert.deepEqual(f, { level: 'critical', role: 'Assassin', problem: '可能空指標', suggestion: '加上 null 檢查' });
});
it('maps 警告/建議 labels to warning/info', () => {
assert.equal(parseBotReviewComment(reviewBody('🟡 警告', 'Mage', 'p', 's')).level, 'warning');
assert.equal(parseBotReviewComment(reviewBody('🔵 建議', 'Bard', 'p', 's')).level, 'info');
});
it('parses inline critical comment format (等級/審查員/建議, no 問題)', () => {
const body = '**等級**:🔴 嚴重\n**審查員**Rogue\n**建議**:移除硬編碼密鑰';
const f = parseBotReviewComment(body);
assert.equal(f.level, 'critical');
assert.equal(f.role, 'Rogue');
assert.equal(f.suggestion, '移除硬編碼密鑰');
});
it('falls back to 問題 content when 建議 is absent', () => {
const body = '**審查員**Maya\n**問題**:缺少邊界測試';
const f = parseBotReviewComment(body);
assert.equal(f.problem, '缺少邊界測試');
assert.equal(f.suggestion, '缺少邊界測試');
});
it('defaults level to warning when 嚴重等級/等級 is missing', () => {
const body = '**審查員**Maya\n**問題**p\n**建議**s';
assert.equal(parseBotReviewComment(body).level, 'warning');
});
it('returns null for free-form human comments', () => {
assert.equal(parseBotReviewComment('我覺得這段可以再想想'), null);
assert.equal(parseBotReviewComment(''), null);
assert.equal(parseBotReviewComment(null), null);
});
});
describe('groupConversations', () => {
it('groups by path+line, detects resolved, and extracts bot finding', () => {
const comments = [
{ id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 's1') },
{ id: 2, path: 'a.js', position: 10, body: '補充:請看這裡', resolver: { login: 'dev' } },
{ id: 3, path: 'b.js', position: 5, body: reviewBody('🟡 警告', 'Mage', 'p2', 's2') },
];
const convos = groupConversations(comments);
assert.equal(convos.length, 2);
const a = convos.find(c => c.path === 'a.js');
assert.equal(a.resolved, true);
assert.deepEqual(a.commentIds, [1, 2]);
assert.equal(a.botFinding.level, 'critical');
assert.equal(a.botFinding.location, 'a.js:10');
const b = convos.find(c => c.path === 'b.js');
assert.equal(b.resolved, false);
assert.equal(b.botFinding.suggestion, 's2');
});
it('falls back to original_position when position is missing', () => {
const convos = groupConversations([{ id: 1, path: 'a.js', original_position: 7, body: 'x' }]);
assert.equal(convos[0].line, 7);
});
});
describe('codeWindow', () => {
it('returns a numbered window around the target line', () => {
const content = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join('\n');
const win = codeWindow(content, 25, 2);
assert.equal(win, '23: line23\n24: line24\n25: line25\n26: line26\n27: line27');
});
it('returns empty string for empty content', () => {
assert.equal(codeWindow('', 10), '');
});
it('handles out-of-range line numbers without throwing', () => {
const content = Array.from({ length: 10 }, (_, i) => `line${i + 1}`).join('\n');
assert.doesNotThrow(() => codeWindow(content, -5, 2));
assert.equal(codeWindow(content, 0, 1), '1: line1\n2: line2'); // 非正數行號 → 從開頭取窗
assert.equal(codeWindow(content, 9999, 2), ''); // 超過檔尾 → 空字串,不丟錯
});
});
describe('judgeConversationsResolved', () => {
it('aligns verdicts by idx and defaults missing/non-true to false', async () => {
const items = [{ idx: 0 }, { idx: 1 }, { idx: 2 }];
const chatFn = async () => [{ idx: 0, resolved: true }, { idx: 1, resolved: false }];
const verdicts = await judgeConversationsResolved(items, chatFn);
assert.deepEqual(verdicts, [
{ idx: 0, resolved: true },
{ idx: 1, resolved: false },
{ idx: 2, resolved: false },
]);
});
it('treats non-array AI output as all unresolved', async () => {
const verdicts = await judgeConversationsResolved([{ idx: 0 }], async () => ({}));
assert.deepEqual(verdicts, [{ idx: 0, resolved: false }]);
});
it('filters out AI results missing idx or resolved and keeps valid ones', async () => {
const items = [{ idx: 0 }, { idx: 1 }];
const chatFn = async () => [{ resolved: true }, { idx: 1, resolved: true }, { idx: 0 }];
const verdicts = await judgeConversationsResolved(items, chatFn);
assert.deepEqual(verdicts, [
{ idx: 0, resolved: false }, // {idx:0} 缺 resolved → 視為 false;缺 idx 的整筆被過濾
{ idx: 1, resolved: true },
]);
});
it('ignores entries whose idx is not an integer (e.g. a string)', async () => {
const items = [{ idx: 0 }, { idx: 1 }];
// 字串 idx '0' 不可冒充整數 idx 0 把它改成 resolved
const chatFn = async () => [{ idx: '0', resolved: true }, { idx: 1.5, resolved: true }, { idx: 1, resolved: true }];
const verdicts = await judgeConversationsResolved(items, chatFn);
assert.deepEqual(verdicts, [
{ idx: 0, resolved: false },
{ idx: 1, resolved: true },
]);
});
it('propagates errors thrown by chatFn to the caller', async () => {
await assert.rejects(
() => judgeConversationsResolved([{ idx: 0 }], async () => { throw new Error('LLM down'); }),
/LLM down/,
);
});
it('returns [] for no items', async () => {
assert.deepEqual(await judgeConversationsResolved([]), []);
});
});
describe('reconcileConversations', () => {
const baseDeps = () => ({
listComments: async () => [
{ id: 1, path: 'a.js', position: 10, body: reviewBody('🔴 嚴重', 'Assassin', 'p1', 'fix one') },
{ id: 2, path: 'b.js', position: 20, body: reviewBody('🟡 警告', 'Mage', 'p2', 'fix two') },
{ id: 3, path: 'c.js', position: 30, body: reviewBody('🔵 建議', 'Bard', 'p3', 'fix three'), resolver: { login: 'dev' } },
],
getFileContent: async () => 'some code',
judge: async (items) => items.map(it => ({ idx: it.idx, resolved: it.path === 'a.js' })),
resolveComment: async () => ({ ok: true }),
});
it('resolves AI-confirmed conversations and carries unresolved ones back', async () => {
const resolvedIds = [];
const deps = baseDeps();
deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; };
const result = await reconcileConversations(deps);
assert.deepEqual(resolvedIds, [1]); // a.js resolved, c.js already resolved (skipped)
assert.equal(result.resolvedCount, 1);
assert.equal(result.unresolvedCount, 1); // only b.js (c.js was skipped as already resolved)
assert.deepEqual(result.resolvedFindings.map(f => f.location), ['a.js:10']);
assert.deepEqual(result.carriedFindings.map(f => f.suggestion), ['fix two']);
});
it('resolves (not carries) an unresolved conversation already present in old findings', async () => {
const resolvedIds = [];
const deps = baseDeps();
deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: false })); // 全部未修復
deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; };
// b.js 的問題已存在於舊問題(檔案+建議簽章相符,行號不同不影響)→ 應解決對話、不加回
deps.oldFindings = [{ location: 'b.js:99', suggestion: 'fix two' }];
const result = await reconcileConversations(deps);
assert.deepEqual(resolvedIds, [2]); // 僅 b.js(id=2) 因已存在舊問題而被 resolve
assert.equal(result.duplicateCount, 1);
assert.equal(result.resolvedCount, 0);
assert.deepEqual(result.carriedFindings.map(f => f.suggestion), ['fix one']); // a.js 不在舊問題 → 加回
assert.deepEqual(result.resolvedFindings, []); // duplicate 不從舊問題移除
});
it('carries the conversation back when the resolve API call fails', async () => {
const deps = baseDeps();
deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: true }));
deps.resolveComment = async () => { throw new Error('403'); };
const result = await reconcileConversations(deps);
assert.equal(result.resolvedCount, 0);
assert.equal(result.carriedFindings.length, 2);
});
it('treats all conversations as unresolved when the judge throws', async () => {
const resolvedIds = [];
const deps = baseDeps();
deps.judge = async () => { throw new Error('judge boom'); };
deps.resolveComment = async (id) => { resolvedIds.push(id); return { ok: true }; };
const result = await reconcileConversations(deps);
assert.deepEqual(resolvedIds, []); // 無任何對話被 resolve
assert.equal(result.resolvedCount, 0);
assert.equal(result.carriedFindings.length, 2); // a.js + b.js 皆加回(c.js 已解決略過)
assert.deepEqual(result.carriedFindings.map(f => f.suggestion).sort(), ['fix one', 'fix two']);
});
it('treats a file as empty and continues when getFileContent throws', async () => {
const deps = baseDeps();
deps.getFileContent = async (path) => { if (path === 'a.js') throw new Error('404'); return 'some code'; };
// judge 收到的 a.js code 應為空字串,仍照常判斷、不丟例外
let seenCode;
deps.judge = async (items) => { seenCode = items.find(it => it.path === 'a.js')?.code; return items.map(it => ({ idx: it.idx, resolved: false })); };
const result = await reconcileConversations(deps);
assert.equal(seenCode, '');
assert.equal(result.resolvedCount, 0);
assert.equal(result.carriedFindings.length, 2); // a.js + b.js 加回(c.js 已解決略過)
});
it('skips path-traversal file paths without calling getFileContent', async () => {
const requested = [];
const deps = baseDeps();
deps.listComments = async () => [
{ id: 1, path: '../../etc/passwd', position: 1, body: reviewBody('🔴 嚴重', 'Assassin', 'p', 's') },
{ id: 2, path: 'b.js', position: 20, body: reviewBody('🟡 警告', 'Mage', 'p2', 'fix two') },
];
deps.getFileContent = async (path) => { requested.push(path); return 'code'; };
deps.judge = async (items) => items.map(it => ({ idx: it.idx, resolved: false }));
await reconcileConversations(deps);
assert.deepEqual(requested, ['b.js']); // 不安全路徑未被請求
});
it('returns empty result and does not throw when listing comments fails', async () => {
const result = await reconcileConversations({ listComments: async () => { throw new Error('boom'); } });
assert.deepEqual(result, { resolvedFindings: [], carriedFindings: [], resolvedCount: 0, unresolvedCount: 0 });
});
it('returns empty result when there are no open conversations', async () => {
const result = await reconcileConversations({
listComments: async () => [{ id: 1, path: 'a.js', position: 1, body: 'x', resolver: { login: 'd' } }],
});
assert.equal(result.resolvedCount, 0);
assert.equal(result.carriedFindings.length, 0);
});
});
describe('dropResolvedFindings', () => {
it('removes findings matching resolved ones by file + suggestion, ignoring line drift', () => {
const findings = [
{ role: 'Assassin', location: 'a.js:19', suggestion: '加上 null 檢查' },
{ role: 'Mage', location: 'b.js:5', suggestion: '保留這個' },
];
const resolved = [{ location: 'a.js:42', suggestion: '加上 null 檢查!' }];
const result = dropResolvedFindings(findings, resolved);
assert.equal(result.length, 1);
assert.equal(result[0].location, 'b.js:5');
});
it('returns input unchanged when no resolved findings', () => {
const findings = [{ location: 'a.js:1', suggestion: 's' }];
assert.equal(dropResolvedFindings(findings, []), findings);
});
});
describe('addCarriedFindings', () => {
it('adds carried findings missing from the list, deduping by file + suggestion', () => {
const findings = [{ role: 'Mage', location: 'b.js:5', suggestion: '保留' }];
const carried = [
{ role: 'Mage', location: 'b.js:9', suggestion: '保留', is_new: false }, // dup -> skip
{ role: 'Assassin', location: 'a.js:10', suggestion: '加回我', is_new: false }, // new -> add
];
const result = addCarriedFindings(findings, carried);
assert.equal(result.length, 2);
assert.equal(result[1].suggestion, '加回我');
});
it('returns input unchanged when no carried findings', () => {
const findings = [{ location: 'a.js:1', suggestion: 's' }];
assert.equal(addCarriedFindings(findings, []), findings);
});
});