From e11b5870ea7c45936206f711b698187fa498cbaf Mon Sep 17 00:00:00 2001 From: linyizhou Date: Wed, 17 Jun 2026 17:09:13 +0800 Subject: [PATCH] feat(sight): add optimization tips and savings breakdown to token savings page --- .../dashboard/src/pages/TokenSavingsPage.tsx | 129 +++++-- .../src/test/TokenSavingsPage.test.tsx | 100 +++++- .../dashboard/src/utils/apiClient.ts | 9 + src/agentsight/src/bin/cli/audit.rs | 1 + src/agentsight/src/server/token_savings.rs | 321 +++++++++++++++++- 5 files changed, 533 insertions(+), 27 deletions(-) diff --git a/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx b/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx index 06ef2f743..59304e3a9 100644 --- a/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx +++ b/src/agentsight/dashboard/src/pages/TokenSavingsPage.tsx @@ -4,7 +4,7 @@ import { PieChart, Pie, Cell, ResponsiveContainer, } from 'recharts'; import { fetchTokenSavings, fetchAgentNames } from '../utils/apiClient'; -import type { SessionSavings, SavingsSummary, OptimizationItem, DiffLine, StrategyBreakdownItem } from '../utils/apiClient'; +import type { SessionSavings, SavingsSummary, OptimizationItem, DiffLine, StrategyBreakdownItem, OptimizationTip } from '../utils/apiClient'; import { DateTimePicker } from '../components/DateTimePicker'; import { SessionIdHelp } from '../components/SessionIdHelp'; @@ -140,26 +140,20 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { return (
- {/* Optimization explanation panel */} - {item.optimization_reason && ( -
-

- 优化说明:{item.optimization_reason} + {/* Explanation banner */} +

+ 💡 +
+

{item.explanation}

+

+ 压缩率 {item.compression_ratio.toFixed(1)}% + {' · '} + 影响后续 {item.compounding_turns} 轮调用 + {' · '} + 复合节省 {fmtTokens(item.compounded_saved)} tokens

- {item.compounding_turns > 1 && ( -

- 累计效果:此优化在后续 {item.compounding_turns} 轮对话中持续生效, - 总计节省 {item.compounded_saved.toLocaleString()} tokens -

- )} -
-
-
- )} +
{/* Line-level diff body */}
@@ -210,6 +204,95 @@ const DiffView: React.FC<{ item: OptimizationItem }> = ({ item }) => { ); }; +// ─── Optimization Tips Panel ───────────────────────────────────────────────── + +const TIP_STYLE: Record = { + success: { icon: '✅', border: 'border-green-200', bg: 'bg-green-50', text: 'text-green-800' }, + info: { icon: '💡', border: 'border-blue-200', bg: 'bg-blue-50', text: 'text-blue-800' }, + warning: { icon: '⚠️', border: 'border-yellow-200', bg: 'bg-yellow-50', text: 'text-yellow-800' }, +}; + +const OptimizationTipsPanel: React.FC<{ tips: OptimizationTip[] }> = ({ tips }) => { + if (tips.length === 0) return null; + return ( +
+

+ 🎯 优化建议 +

+
+ {tips.map((tip, idx) => { + const style = TIP_STYLE[tip.level] || TIP_STYLE.info; + return ( +
+ {style.icon} +
+

{tip.title}

+

{tip.description}

+
+
+ ); + })} +
+
+ ); +}; + +// ─── Savings Breakdown Panel ───────────────────────────────────────────────── + +const SavingsBreakdownPanel: React.FC<{ sessions: SessionSavings[] }> = ({ sessions }) => { + // Get top 5 optimization items across all sessions by compounded_saved + const allItems = sessions.flatMap(s => + s.optimization_items.map(item => ({ + ...item, + session_id: s.session_id, + agent_name: s.agent_name, + })) + ); + const topItems = [...allItems] + .sort((a, b) => b.compounded_saved - a.compounded_saved) + .slice(0, 5); + + if (topItems.length === 0) return null; + + const maxSaved = topItems[0]?.compounded_saved || 1; + + return ( +
+

+ 📊 节省排行 Top 5(按复合节省量) +

+
+ {topItems.map((item, idx) => { + const cfg = CATEGORY_CONFIG[item.category]; + const pct = (item.compounded_saved / maxSaved) * 100; + return ( +
+ #{idx + 1} + + {cfg.label} + +
+
+
+ + {fmtTokens(item.compounded_saved)} tokens + +
+
+ + {item.agent_name} + +
+ ); + })} +
+
+ ); +}; + // ─── Optimization table row ─────────────────────────────────────────────────── const OptimizationTableRow: React.FC<{ item: OptimizationItem }> = ({ item }) => { @@ -402,6 +485,7 @@ export const TokenSavingsPage: React.FC = () => { const [sessions, setSessions] = useState([]); const [summary, setSummary] = useState(null); const [statsAvailable, setStatsAvailable] = useState(true); + const [tips, setTips] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [agentNames, setAgentNames] = useState([]); @@ -427,6 +511,7 @@ export const TokenSavingsPage: React.FC = () => { setSessions(resp.sessions); setSummary(resp.summary); setStatsAvailable(resp.stats_available); + setTips(resp.optimization_tips ?? []); } catch (e: any) { setError(e.message || 'Failed to fetch token savings'); } finally { @@ -744,6 +829,12 @@ export const TokenSavingsPage: React.FC = () => { ); })()} + {/* ── Optimization tips + Savings breakdown ── */} +
+ + +
+ {/* ── Session table ── */}
diff --git a/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx b/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx index 45654a438..4f6363b61 100644 --- a/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx +++ b/src/agentsight/dashboard/src/test/TokenSavingsPage.test.tsx @@ -91,6 +91,7 @@ describe('TokenSavingsPage', () => { sessions: [], summary: { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0, total_compounded_saved: 0, total_compounded_tool_saved: 0, total_compounded_mcp_saved: 0, compounded_savings_rate: 0 }, stats_available: false, + optimization_tips: [{ level: 'warning', title: '未检测到 Tokenless 组件', description: '未发现 stats.db,请确认 tokenless 组件已安装并启用。' }], }); await act(async () => { renderPage(); }); await act(async () => { @@ -116,6 +117,7 @@ describe('TokenSavingsPage', () => { ], }, stats_available: true, + optimization_tips: [{ level: 'success', title: '节省效果良好', description: '当前复合节省率 25.0%,已达到良好水平。' }], }); await act(async () => { renderPage(); }); await act(async () => { @@ -153,6 +155,7 @@ describe('TokenSavingsPage', () => { strategy_breakdown: [], }, stats_available: true, + optimization_tips: [], }); await act(async () => { renderPage(); }); await act(async () => { @@ -180,6 +183,9 @@ describe('TokenSavingsPage', () => { before_tokens: 400, after_tokens: 100, compounded_saved: 300, + compression_ratio: 75.0, + explanation: '工具输出优化: 原始 400 tokens → 100 tokens,压缩率 75.0%。后续 1 轮LLM调用均受益,复合节省 300 tokens。', + compounding_turns: 1, before_text: 'long original text', after_text: 'short text', diff_lines: [], @@ -198,19 +204,30 @@ describe('TokenSavingsPage', () => { ], }, stats_available: true, + optimization_tips: [], }); await act(async () => { renderPage(); }); await act(async () => { fireEvent.click(screen.getByText('查询')); }); - // Click session row to expand - const row = screen.getByText('Expander').closest('tr'); + // Click session row to expand - find the one inside the table (has ancestor) + const allExpander = screen.getAllByText('Expander'); + const row = allExpander.map(el => el.closest('tr')).find(tr => tr !== null); await act(async () => { fireEvent.click(row!); }); - expect(screen.getByText('工具输出')).toBeInTheDocument(); - expect(screen.getByText('Schema 压缩')).toBeInTheDocument(); + expect(screen.getAllByText('工具输出').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Schema 压缩').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('详情').length).toBeGreaterThanOrEqual(1); + // Click "详情" to expand DiffView and verify compression_ratio renders + const detailBtns = screen.getAllByText('详情'); + await act(async () => { + fireEvent.click(detailBtns[0]); + }); + // The explanation banner should show compression ratio text + expect(screen.getByText(/75\.0%/)).toBeInTheDocument(); + // The explanation text itself + expect(screen.getByText(/压缩率/)).toBeInTheDocument(); }); it('should show savings rate badge as 优秀 when >= 30%', async () => { @@ -227,6 +244,7 @@ describe('TokenSavingsPage', () => { strategy_breakdown: [], }, stats_available: true, + optimization_tips: [{ level: 'success', title: '节省效果优秀', description: '当前复合节省率 50.0%,表现优秀!继续保持当前配置。' }], }); await act(async () => { renderPage(); }); await act(async () => { @@ -234,4 +252,78 @@ describe('TokenSavingsPage', () => { }); expect(screen.getByText('优秀')).toBeInTheDocument(); }); + + it('should render optimization tips panel', async () => { + mockFetchTokenSavings.mockResolvedValue({ + sessions: [], + summary: { + total_input_tokens: 5000, + total_output_tokens: 3000, + total_tokens: 8000, + total_compounded_saved: 200, + total_compounded_tool_saved: 200, + total_compounded_mcp_saved: 0, + compounded_savings_rate: 2.5, + }, + stats_available: true, + optimization_tips: [ + { level: 'warning', title: '节省率较低', description: '当前复合节省率不足 5%,建议检查 tokenless 配置。' }, + { level: 'info', title: '建议开启 MCP 响应压缩', description: '当前仅有工具输出优化,未检测到 MCP 响应压缩。开启后可进一步降低 Token 消耗。' }, + ], + }); + await act(async () => { renderPage(); }); + await act(async () => { + fireEvent.click(screen.getByText('查询')); + }); + expect(screen.getByText('优化建议')).toBeInTheDocument(); + expect(screen.getByText('节省率较低')).toBeInTheDocument(); + expect(screen.getByText('建议开启 MCP 响应压缩')).toBeInTheDocument(); + }); + + it('should render savings breakdown panel with top items', async () => { + mockFetchTokenSavings.mockResolvedValue({ + sessions: [{ + session_id: 'sess-breakdown', + agent_name: 'BreakdownAgent', + total_input_tokens: 3000, + total_output_tokens: 1500, + saved_tokens: 800, + compounded_saved: 800, + compounded_savings_rate: 17.8, + optimization_items: [ + { + id: 'item-1', + category: 'tool_output', + before_tokens: 500, + after_tokens: 100, + saved_tokens: 400, + compounded_saved: 800, + compounding_turns: 2, + compression_ratio: 80.0, + explanation: '工具输出优化: 原始 500 tokens → 100 tokens,压缩率 80.0%。', + before_text: 'original', + after_text: 'compressed', + diff_lines: [], + }, + ], + }], + summary: { + total_input_tokens: 3000, + total_output_tokens: 1500, + total_tokens: 4500, + total_compounded_saved: 800, + total_compounded_tool_saved: 800, + total_compounded_mcp_saved: 0, + compounded_savings_rate: 17.8, + }, + stats_available: true, + optimization_tips: [], + }); + await act(async () => { renderPage(); }); + await act(async () => { + fireEvent.click(screen.getByText('查询')); + }); + expect(screen.getByText('节省排行 Top 5(按复合节省量)')).toBeInTheDocument(); + expect(screen.getAllByText('BreakdownAgent').length).toBeGreaterThanOrEqual(1); + }); }); diff --git a/src/agentsight/dashboard/src/utils/apiClient.ts b/src/agentsight/dashboard/src/utils/apiClient.ts index 0dc43cf76..e7cb96508 100644 --- a/src/agentsight/dashboard/src/utils/apiClient.ts +++ b/src/agentsight/dashboard/src/utils/apiClient.ts @@ -211,6 +211,8 @@ export interface OptimizationItem { saved_tokens: number; compounded_saved: number; compounding_turns: number; + compression_ratio: number; + explanation: string; before_summary: string; after_summary: string; optimization_reason: string; @@ -259,10 +261,17 @@ export interface SavingsSummary { strategy_breakdown: StrategyBreakdownItem[]; } +export interface OptimizationTip { + level: 'success' | 'info' | 'warning'; + title: string; + description: string; +} + export interface TokenSavingsResponse { stats_available: boolean; summary: SavingsSummary; sessions: SessionSavings[]; + optimization_tips: OptimizationTip[]; } /** diff --git a/src/agentsight/src/bin/cli/audit.rs b/src/agentsight/src/bin/cli/audit.rs index b65bad063..b067f232b 100644 --- a/src/agentsight/src/bin/cli/audit.rs +++ b/src/agentsight/src/bin/cli/audit.rs @@ -222,6 +222,7 @@ mod tests { args: Some(args.into()), exit_code: None, }, + session_id: None, } } diff --git a/src/agentsight/src/server/token_savings.rs b/src/agentsight/src/server/token_savings.rs index 2a2d3230d..ee5a1a6bc 100644 --- a/src/agentsight/src/server/token_savings.rs +++ b/src/agentsight/src/server/token_savings.rs @@ -62,6 +62,8 @@ pub struct OptimizationItemDto { pub saved_tokens: i64, pub compounded_saved: i64, pub compounding_turns: i64, + pub compression_ratio: f64, + pub explanation: String, pub before_summary: String, pub after_summary: String, pub optimization_reason: String, @@ -79,7 +81,7 @@ pub struct DiffLineDto { } /// Per-session savings data -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] pub struct SessionSavingsDto { pub session_id: String, pub agent_name: String, @@ -97,12 +99,21 @@ pub struct SessionSavingsDto { pub optimization_items: Vec, } +/// An actionable optimization tip +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct OptimizationTip { + pub level: String, + pub title: String, + pub description: String, +} + /// Full response for /api/token-savings #[derive(Debug, Serialize)] pub struct TokenSavingsResponse { pub stats_available: bool, pub summary: SavingsSummary, pub sessions: Vec, + pub optimization_tips: Vec, } /// Response for /api/token-savings/session/{session_id} @@ -318,6 +329,119 @@ fn now_ns() -> u64 { .as_nanos() as u64 } +/// Compute compression ratio clamped to [0.0, 100.0]. +/// Returns 0.0 when before_tokens is 0 or after >= before (no real compression). +pub(crate) fn compute_compression_ratio(before_tokens: i64, after_tokens: i64) -> f64 { + if before_tokens > 0 { + let raw = (1.0 - after_tokens as f64 / before_tokens as f64) * 100.0; + raw.clamp(0.0, 100.0) + } else { + 0.0 + } +} + +/// Build a human-readable explanation string for an optimization item. +pub(crate) fn build_explanation( + category: &str, + before_tokens: i64, + after_tokens: i64, + compression_ratio: f64, + compounding_turns: i64, + compounded: i64, +) -> String { + if category == "mcp_response" { + format!( + "MCP响应压缩: 原始 {} tokens → {} tokens,压缩率 {:.1}%。后续 {} 轮LLM调用均受益,复合节省 {} tokens。", + before_tokens, after_tokens, compression_ratio, compounding_turns, compounded + ) + } else { + format!( + "工具输出优化: 原始 {} tokens → {} tokens,压缩率 {:.1}%。后续 {} 轮LLM调用均受益,复合节省 {} tokens。", + before_tokens, after_tokens, compression_ratio, compounding_turns, compounded + ) + } +} + +/// Generate optimization tips based on aggregated savings data. +pub(crate) fn generate_optimization_tips( + stats_available: bool, + grand_total: i64, + grand_compounded_rate: f64, + grand_compounded_tool_saved: i64, + grand_compounded_mcp_saved: i64, + sessions: &[SessionSavingsDto], +) -> Vec { + let mut tips: Vec = Vec::new(); + + if !stats_available { + tips.push(OptimizationTip { + level: "warning".to_string(), + title: "未检测到 Tokenless 组件".to_string(), + description: "未发现 stats.db,请确认 tokenless 组件已安装并启用。启用后可自动压缩工具输出和 MCP 响应,显著降低 Token 消耗。".to_string(), + }); + } else if grand_compounded_rate < 15.0 && grand_total > 0 { + tips.push(OptimizationTip { + level: "warning".to_string(), + title: "节省率较低".to_string(), + description: "当前复合节省率不足 15%,建议检查 tokenless 配置是否已对所有 Agent 生效,确保工具输出和 MCP 响应压缩均已开启。".to_string(), + }); + } + + if grand_compounded_tool_saved > 0 && grand_compounded_mcp_saved == 0 && grand_total > 0 { + tips.push(OptimizationTip { + level: "info".to_string(), + title: "建议开启 MCP 响应压缩".to_string(), + description: + "当前仅有工具输出优化,未检测到 MCP 响应压缩。开启后可进一步降低 Token 消耗。" + .to_string(), + }); + } + + if grand_compounded_mcp_saved > 0 && grand_compounded_tool_saved == 0 && grand_total > 0 { + tips.push(OptimizationTip { + level: "info".to_string(), + title: "建议开启工具输出优化".to_string(), + description: + "当前仅有 MCP 响应压缩,未检测到工具输出优化。开启后可进一步降低 Token 消耗。" + .to_string(), + }); + } + + let zero_savings_sessions = sessions + .iter() + .filter(|s| s.compounded_saved == 0 && s.total_tokens > 1000) + .count(); + if zero_savings_sessions > 0 { + tips.push(OptimizationTip { + level: "info".to_string(), + title: format!("发现 {} 个未优化会话", zero_savings_sessions), + description: "部分会话消耗较高但无优化记录,可能是对应 Agent 未启用 tokenless 或工具调用较少。建议检查这些会话的 Agent 配置。".to_string(), + }); + } + + if grand_compounded_rate >= 30.0 { + tips.push(OptimizationTip { + level: "success".to_string(), + title: "节省效果优秀".to_string(), + description: format!( + "当前复合节省率 {:.1}%,表现优秀!继续保持当前配置。", + grand_compounded_rate + ), + }); + } else if grand_compounded_rate >= 15.0 { + tips.push(OptimizationTip { + level: "success".to_string(), + title: "节省效果良好".to_string(), + description: format!( + "当前复合节省率 {:.1}%,已达到良好水平。可尝试调整压缩策略以进一步提升。", + grand_compounded_rate + ), + }); + } + + tips +} + // ─── GET /api/token-savings ────────────────────────────────────────────────── /// GET /api/token-savings?start_ns=&end_ns=&agent_name= @@ -449,8 +573,6 @@ pub async fn get_token_savings( let strategy_label = map_operation_to_strategy_label(&row.operation).to_string(); // FIX(#2): aggregate by strategy key so unknown ops merge into one slice. - // Use operation name as key for known ops (frontend STRATEGY_CONFIG matches on this), - // and "other" for unknown ops so they collapse into a single slice. let strategy_key = match row.operation.as_str() { "compress-response" | "compress-toon" | "rewrite-command" | "compress-schema" => row.operation.clone(), @@ -464,6 +586,17 @@ pub async fn get_token_savings( entry.1 += saved; entry.2 += compounded; + let compression_ratio = + compute_compression_ratio(row.before_tokens, row.after_tokens); + let explanation = build_explanation( + category, + row.before_tokens, + row.after_tokens, + compression_ratio, + compounding_turns, + compounded, + ); + items.push(OptimizationItemDto { id: row.tool_use_id.clone(), category: category.to_string(), @@ -475,6 +608,8 @@ pub async fn get_token_savings( saved_tokens: saved, compounded_saved: compounded, compounding_turns, + compression_ratio, + explanation, before_summary: format!("原始内容 {} tokens", row.before_tokens), after_summary: format!("优化后 {} tokens", row.after_tokens), optimization_reason, @@ -567,7 +702,15 @@ pub async fn get_token_savings( total_compounded_mcp_saved: grand_compounded_mcp_saved, strategy_breakdown, }, - sessions: resp_sessions, + sessions: resp_sessions.clone(), + optimization_tips: generate_optimization_tips( + stats_available, + grand_total, + grand_compounded_rate, + grand_compounded_tool_saved, + grand_compounded_mcp_saved, + &resp_sessions, + ), }) } @@ -671,6 +814,15 @@ pub async fn get_session_savings( saved_tokens: saved, compounded_saved: compounded, compounding_turns, + compression_ratio: compute_compression_ratio(row.before_tokens, row.after_tokens), + explanation: build_explanation( + category, + row.before_tokens, + row.after_tokens, + compute_compression_ratio(row.before_tokens, row.after_tokens), + compounding_turns, + compounded, + ), before_summary: format!("原始内容 {} tokens", row.before_tokens), after_summary: format!("优化后 {} tokens", row.after_tokens), optimization_reason: generate_optimization_reason( @@ -1119,6 +1271,27 @@ mod tests { } } + // ─── Unit tests for helper functions ───────────────────────────────── + + fn make_session_for_tips(compounded_saved: i64, total_tokens: i64) -> SessionSavingsDto { + SessionSavingsDto { + session_id: "sess-1".to_string(), + agent_name: "TestAgent".to_string(), + total_input_tokens: total_tokens / 2, + total_output_tokens: total_tokens / 2, + total_tokens, + baseline_tokens: total_tokens + compounded_saved, + saved_tokens: compounded_saved, + compounded_saved, + savings_rate: 0.0, + compounded_savings_rate: 0.0, + request_count: 1, + tool_saved: 0, + mcp_saved: 0, + optimization_items: vec![], + } + } + #[test] fn test_diff_replace_remove_before_add() { let before = "old_line"; @@ -1132,6 +1305,65 @@ mod tests { ); } + #[test] + fn test_compute_compression_ratio_normal() { + let ratio = compute_compression_ratio(1000, 250); + assert!((ratio - 75.0).abs() < 0.01); + } + + #[test] + fn test_compute_compression_ratio_zero_before() { + assert_eq!(compute_compression_ratio(0, 100), 0.0); + } + + #[test] + fn test_compute_compression_ratio_no_compression() { + let ratio = compute_compression_ratio(500, 500); + assert!((ratio - 0.0).abs() < 0.01); + } + + #[test] + fn test_compute_compression_ratio_negative_clamped() { + let ratio = compute_compression_ratio(100, 200); + assert_eq!(ratio, 0.0); + } + + #[test] + fn test_build_explanation_mcp_response() { + let explanation = build_explanation("mcp_response", 1000, 200, 80.0, 3, 2400); + assert!(explanation.contains("MCP")); + assert!(explanation.contains("1000")); + assert!(explanation.contains("200")); + assert!(explanation.contains("80.0%")); + assert!(explanation.contains("3")); + assert!(explanation.contains("2400")); + } + + #[test] + fn test_build_explanation_tool_output() { + let explanation = build_explanation("tool_output", 500, 100, 80.0, 2, 800); + assert!(explanation.contains("工具输出")); + assert!(explanation.contains("500")); + assert!(explanation.contains("100")); + } + + #[test] + fn test_tips_stats_unavailable() { + let tips = generate_optimization_tips(false, 0, 0.0, 0, 0, &[]); + assert_eq!(tips.len(), 1); + assert_eq!(tips[0].level, "warning"); + assert!(tips[0].title.contains("Tokenless")); + } + + #[test] + fn test_tips_low_savings_rate() { + let tips = generate_optimization_tips(true, 10000, 10.0, 100, 200, &[]); + assert!( + tips.iter() + .any(|t| t.level == "warning" && t.title.contains("节省率")) + ); + } + #[test] fn test_diff_large_input_fallback() { let big_before = (0..1200) @@ -1169,4 +1401,85 @@ mod tests { let result = compute_diff_lines(Some(&before), Some(&after)); assert!(result.len() <= 200); } + + #[test] + fn test_tips_boundary_at_15_no_warning() { + let tips = generate_optimization_tips(true, 10000, 15.0, 1000, 500, &[]); + assert!( + !tips + .iter() + .any(|t| t.level == "warning" && t.title.contains("节省率")) + ); + assert!(tips.iter().any(|t| t.level == "success")); + } + + #[test] + fn test_tips_boundary_at_30_excellent() { + let tips = generate_optimization_tips(true, 10000, 30.0, 2000, 1000, &[]); + assert!( + tips.iter() + .any(|t| t.level == "success" && t.title.contains("优秀")) + ); + } + + #[test] + fn test_tips_boundary_just_below_15_warning() { + let tips = generate_optimization_tips(true, 10000, 14.9, 500, 500, &[]); + assert!( + tips.iter() + .any(|t| t.level == "warning" && t.title.contains("节省率")) + ); + } + + #[test] + fn test_tips_only_tool_saved_suggest_mcp() { + let tips = generate_optimization_tips(true, 10000, 10.0, 500, 0, &[]); + assert!( + tips.iter() + .any(|t| t.level == "info" && t.title.contains("MCP")) + ); + } + + #[test] + fn test_tips_only_mcp_saved_suggest_tool() { + let tips = generate_optimization_tips(true, 10000, 10.0, 0, 500, &[]); + assert!( + tips.iter() + .any(|t| t.level == "info" && t.title.contains("工具")) + ); + } + + #[test] + fn test_tips_zero_savings_sessions() { + let sessions = vec![ + make_session_for_tips(0, 5000), + make_session_for_tips(200, 3000), + ]; + let tips = generate_optimization_tips(true, 8000, 10.0, 100, 100, &sessions); + assert!(tips.iter().any(|t| t.title.contains("1"))); + } + + #[test] + fn test_tips_excellent_rate() { + let tips = generate_optimization_tips(true, 10000, 35.0, 2000, 1500, &[]); + assert!( + tips.iter() + .any(|t| t.level == "success" && t.title.contains("优秀")) + ); + } + + #[test] + fn test_tips_good_rate() { + let tips = generate_optimization_tips(true, 10000, 20.0, 1000, 1000, &[]); + assert!( + tips.iter() + .any(|t| t.level == "success" && t.title.contains("良好")) + ); + } + + #[test] + fn test_tips_empty_when_no_data() { + let tips = generate_optimization_tips(true, 0, 0.0, 0, 0, &[]); + assert!(tips.is_empty()); + } }