From a27555b35ae52be59d853fbff8cb294c39947912 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 23 Jun 2026 12:58:43 +0800 Subject: [PATCH] =?UTF-8?q?test(ai-review):=20=E8=A3=9C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E7=B5=B1=E8=A8=88=E8=88=87=20resolve=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=88=87=E9=82=8A=E7=95=8C=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comments.test.js | 24 ++++++ app/resolve.test.js | 27 ++++++ app/usage.test.js | 196 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 app/usage.test.js diff --git a/app/comments.test.js b/app/comments.test.js index e8db471..66972ba 100644 --- a/app/comments.test.js +++ b/app/comments.test.js @@ -281,6 +281,30 @@ describe('postFindingsReview', () => { assert.match(reviewCalls[0].comments[0].body, /建議.*C/s); }); + it('appends the usage section to the review body when provided', async () => { + const reviewCalls = []; + await postFindingsReview([ + { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true }, + ], { + postReview: async (args) => { reviewCalls.push(args); }, + usageSection: '## 🤖 AI 助理使用量\n\n本次:120 token', + }); + + assert.match(reviewCalls[0].body, /## AI Code Review 統計/); + assert.match(reviewCalls[0].body, /## 🤖 AI 助理使用量\n\n本次:120 token$/); + }); + + it('omits the usage section when not provided', async () => { + const reviewCalls = []; + await postFindingsReview([ + { level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true }, + ], { + postReview: async (args) => { reviewCalls.push(args); }, + }); + + assert.doesNotMatch(reviewCalls[0].body, /AI 助理使用量/); + }); + it('separates old and new findings in default review statistics', async () => { const reviewCalls = []; await postFindingsReview([ diff --git a/app/resolve.test.js b/app/resolve.test.js index 0d8410d..9e18840 100644 --- a/app/resolve.test.js +++ b/app/resolve.test.js @@ -177,6 +177,33 @@ describe('reconcileConversations', () => { 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 }); diff --git a/app/usage.test.js b/app/usage.test.js new file mode 100644 index 0000000..165593b --- /dev/null +++ b/app/usage.test.js @@ -0,0 +1,196 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractUsage, + recordUsage, + getRunUsage, + resetRunUsage, + recordRateLimit, + getRateLimit, + resetRateLimit, + resolveRemainingPercent, + fetchAccountQuota, + formatUsageStats, + formatUsageStatsLine, +} from './usage.js'; + +describe('extractUsage', () => { + it('parses OpenAI-compatible usage', () => { + const u = extractUsage({ usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }); + assert.deepEqual(u, { promptTokens: 100, completionTokens: 20, totalTokens: 120 }); + }); + + it('parses OpenAI Responses input/output tokens and derives total', () => { + const u = extractUsage({ usage: { input_tokens: 50, output_tokens: 10 } }); + assert.deepEqual(u, { promptTokens: 50, completionTokens: 10, totalTokens: 60 }); + }); + + it('parses Gemini usageMetadata', () => { + const u = extractUsage({ usageMetadata: { promptTokenCount: 30, candidatesTokenCount: 5, totalTokenCount: 35 } }); + assert.deepEqual(u, { promptTokens: 30, completionTokens: 5, totalTokens: 35 }); + }); + + it('parses Ollama native eval counts', () => { + const u = extractUsage({ prompt_eval_count: 12, eval_count: 8 }); + assert.deepEqual(u, { promptTokens: 12, completionTokens: 8, totalTokens: 20 }); + }); + + it('parses OpenCode tokens from info.tokens', () => { + const u = extractUsage({ info: { tokens: { input: 7, output: 3 } } }); + assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, totalTokens: 10 }); + }); + + it('returns null when no usage info is present', () => { + assert.equal(extractUsage({ choices: [{ message: { content: 'hi' } }] }), null); + assert.equal(extractUsage(null), null); + }); +}); + +describe('recordUsage / getRunUsage', () => { + beforeEach(() => resetRunUsage()); + + it('accumulates across calls and counts every call', () => { + recordUsage({ usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }); + recordUsage({ usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 } }); + recordUsage({ parts: [] }); // no usage → still counts as a call + assert.deepEqual(getRunUsage(), { calls: 3, promptTokens: 15, completionTokens: 3, totalTokens: 18 }); + }); + + it('returns a copy, not the internal object', () => { + const a = getRunUsage(); + a.calls = 999; + assert.equal(getRunUsage().calls, 0); + }); +}); + +describe('fetchAccountQuota', () => { + it('reads OpenRouter credits via injected get', async () => { + const get = async (url, opts) => { + assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/); + assert.equal(opts.headers.Authorization, 'Bearer sk-or-xxx'); + return { data: { data: { usage: 12.4, limit: 100, limit_remaining: 87.6 } } }; + }; + const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); + assert.deepEqual(q, { available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD', source: 'openrouter' }); + }); + + it('derives remaining when OpenRouter omits limit_remaining', async () => { + const get = async () => ({ data: { data: { usage: 10, limit: 50 } } }); + const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); + assert.equal(q.remaining, 40); + }); + + it('reports unavailable for plain OpenAI (no openrouter)', async () => { + const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://api.openai.com/v1' }, { get: async () => { throw new Error('should not call'); } }); + assert.equal(q.available, false); + assert.match(q.reason, /API key 無法取得/); + }); + + it('reports 不適用 for local platforms', async () => { + assert.equal((await fetchAccountQuota('ollama', {})).available, false); + assert.equal((await fetchAccountQuota('opencode', {})).available, false); + }); + + it('degrades gracefully when the quota call throws', async () => { + const get = async () => { throw new Error('network down'); }; + const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); + assert.deepEqual(q, { available: false, reason: 'network down' }); + }); + + it('reports unsupported provider', async () => { + const q = await fetchAccountQuota('mystery', {}); + assert.equal(q.available, false); + assert.match(q.reason, /未支援/); + }); +}); + +describe('recordRateLimit / getRateLimit', () => { + beforeEach(() => resetRateLimit()); + + it('captures OpenAI-style token rate-limit headers (case-insensitive)', () => { + recordRateLimit({ 'X-RateLimit-Remaining-Tokens': '190000', 'X-RateLimit-Limit-Tokens': '200000' }); + assert.deepEqual(getRateLimit(), { hasData: true, remaining: 190000, limit: 200000, kind: 'tokens' }); + }); + + it('captures Anthropic-style token rate-limit headers', () => { + recordRateLimit({ 'anthropic-ratelimit-tokens-remaining': '8000', 'anthropic-ratelimit-tokens-limit': '10000' }); + assert.deepEqual(getRateLimit(), { hasData: true, remaining: 8000, limit: 10000, kind: 'tokens' }); + }); + + it('falls back to request-dimension headers when token headers are absent', () => { + recordRateLimit({ 'x-ratelimit-remaining-requests': '45', 'x-ratelimit-limit-requests': '60' }); + assert.deepEqual(getRateLimit(), { hasData: true, remaining: 45, limit: 60, kind: 'requests' }); + }); + + it('ignores responses without rate-limit headers', () => { + recordRateLimit({ 'content-type': 'application/json' }); + assert.equal(getRateLimit().hasData, false); + }); +}); + +describe('resolveRemainingPercent', () => { + it('prefers account quota when a finite limit exists', () => { + const pct = resolveRemainingPercent({ available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD' }, { hasData: true, remaining: 1, limit: 10, kind: 'tokens' }); + assert.equal(pct.percent, 87.6); + assert.equal(pct.basis, '帳號額度'); + }); + + it('derives remaining from used when quota.remaining is absent', () => { + const pct = resolveRemainingPercent({ available: true, used: 25, limit: 100, currency: 'USD' }, null); + assert.equal(pct.percent, 75); + }); + + it('falls back to rate-limit window percent when quota has no limit', () => { + const pct = resolveRemainingPercent({ available: false, reason: 'x' }, { hasData: true, remaining: 150000, limit: 200000, kind: 'tokens' }); + assert.equal(pct.percent, 75); + assert.match(pct.basis, /速率配額(當前視窗,token)/); + }); + + it('returns null percent with a reason when nothing is available', () => { + const pct = resolveRemainingPercent({ available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); + assert.equal(pct.percent, null); + assert.equal(pct.reason, '本地服務,無帳號額度概念'); + }); + + it('explains an unlimited account cannot yield a percent', () => { + const pct = resolveRemainingPercent({ available: true, used: 5, limit: null }, { hasData: false }); + assert.equal(pct.percent, null); + assert.match(pct.reason, /無上限/); + }); +}); + +describe('formatUsageStats', () => { + const usage = { calls: 7, promptTokens: 18432, completionTokens: 2107, totalTokens: 20539 }; + + it('renders token table and remaining percent from account quota', () => { + const out = formatUsageStats('openai', 'gpt-4o-mini', usage, { available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD' }, null); + assert.match(out, /## 🤖 AI 助理使用量/); + assert.match(out, /18,432 \| 2,107 \| 20,539/); + assert.match(out, /共 7 次呼叫/); + assert.match(out, /剩餘可用 \*\*87.6%\*\*(帳號額度:USD 87.6 \/ USD 100)/); + }); + + it('renders remaining percent from rate-limit window when quota is unavailable', () => { + const out = formatUsageStats('claude', 'sonnet', usage, { available: false, reason: 'r' }, { hasData: true, remaining: 150000, limit: 200000, kind: 'tokens' }); + assert.match(out, /剩餘可用 \*\*75%\*\*(速率配額(當前視窗,token):150,000 \/ 200,000)/); + }); + + it('explains when no percentage can be computed', () => { + const out = formatUsageStats('ollama', 'llama3', usage, { available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); + assert.match(out, /剩餘可用:無法計算百分比(本地服務,無帳號額度概念)/); + }); +}); + +describe('formatUsageStatsLine', () => { + const usage = { calls: 3, promptTokens: 100, completionTokens: 20, totalTokens: 120 }; + + it('summarises tokens and remaining percent on one line', () => { + const line = formatUsageStatsLine('openai', 'gpt-4o-mini', usage, { available: true, used: 1, limit: 10, remaining: 9, currency: 'USD' }, null); + assert.equal(line, '本次 openai/gpt-4o-mini: 提示100 + 回應20 = 120 token(3 次呼叫);剩餘可用: 90%(帳號額度 USD 9/USD 10)'); + }); + + it('notes when remaining percent cannot be computed', () => { + const line = formatUsageStatsLine('ollama', 'llama3', usage, { available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); + assert.match(line, /;剩餘可用: 無法計算(本地服務,無帳號額度概念)/); + }); +});