// 單元測試:recordAnalytics — 執行統計回寫 // SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」 import { describe, it, expect, beforeEach } from 'vitest'; import { recordAnalytics } from '../src/actions/recordAnalytics'; import type { Bindings } from '../src/types'; // 最小 KV mock(get/put/list,In-memory) function makeKv() { const store = new Map(); return { store, async get(key: string) { return store.get(key) ?? null; }, async put(key: string, value: string) { store.set(key, value); }, async list({ prefix }: { prefix: string }) { return { keys: [...store.keys()].filter(k => k.startsWith(prefix)).map(name => ({ name })) }; }, }; } describe('recordAnalytics', () => { let submissions: ReturnType; let analytics: ReturnType; let env: Bindings; beforeEach(() => { submissions = makeKv(); analytics = makeKv(); env = { SUBMISSIONS_KV: submissions, ANALYTICS_KV: analytics } as unknown as Bindings; // 種一顆零件(indexOnlyComponent 的記錄形狀) submissions.store.set('idx:http_request', 'cmp_abc12345'); submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({ component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', display_name: 'HTTP Request', version: 'v1', success_rate: 1, avg_duration_ms: 0, call_count: 0, visibility: 'public', status: 'active', })); }); it('N 次成功+M 次失敗 → success_rate = success_runs / total_runs,計數器與 comp 記錄同步', async () => { // 3 成功 + 2 失敗 for (const success of [true, true, true, false, false]) { const r = await recordAnalytics({ canonical_id: 'http_request', success, duration_ms: 100 }, env); expect(r.ok).toBe(true); } // 真相源:ANALYTICS_KV 計數器 const counters = JSON.parse(analytics.store.get('stats:cmp_abc12345:v1')!); expect(counters).toEqual({ total_runs: 5, success_runs: 3, total_ms: 500 }); // 讀取端:comp 記錄被回填衍生值 const record = JSON.parse(submissions.store.get('comp:cmp_abc12345:v1')!); expect(record.success_rate).toBeCloseTo(3 / 5); expect(record.avg_duration_ms).toBe(100); expect(record.call_count).toBe(5); }); it('接受 cmp_ hash_id 直接記錄', async () => { const r = await recordAnalytics({ canonical_id: 'cmp_abc12345', success: true, duration_ms: 50 }, env); expect(r.ok).toBe(true); expect(r.canonical_id).toBe('http_request'); expect(r.total_runs).toBe(1); }); it('指定 version 時記到該版本', async () => { submissions.store.set('comp:cmp_abc12345:v2', JSON.stringify({ component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', version: 'v2', success_rate: 1, avg_duration_ms: 0, call_count: 0, visibility: 'public', status: 'active', })); const r = await recordAnalytics({ canonical_id: 'http_request', version: 'v2', success: false, duration_ms: 30 }, env); expect(r.ok).toBe(true); expect(r.version).toBe('v2'); expect(analytics.store.has('stats:cmp_abc12345:v2')).toBe(true); expect(analytics.store.has('stats:cmp_abc12345:v1')).toBe(false); }); it('不在索引的零件回 ok:false(誠實 404,不假綠)', async () => { const r = await recordAnalytics({ canonical_id: 'no_such_component', success: true, duration_ms: 1 }, env); expect(r.ok).toBe(false); expect(r.error).toContain('不在索引'); }); it('tombstone 版本不被記錄', async () => { submissions.store.set('comp:cmp_abc12345:v1', JSON.stringify({ component_hash_id: 'cmp_abc12345', canonical_id: 'http_request', version: 'v1', status: 'tombstone', })); const r = await recordAnalytics({ canonical_id: 'http_request', success: true, duration_ms: 1 }, env); expect(r.ok).toBe(false); }); });