| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- """
- Coverage补充测试:mcp_protocol.py
- 目标路径:
- - run_stdio — ValueError 导致的 parse error 响应、空行跳过、非 dict message
- - handle_request — 未知 method、非 tools/call 的异常
- - _render_text — 无 columns/track 字段时的 json.dumps fallback
- - _render_text — records 存在但第一条无 status/content/time 时 fallback
- - _render_track_like_text — location / tracking_number / shipment_id / sub_track 可选字段
- - _render_track_like_text — 空 records + tips
- - _render_table_like_text — 空 records + tips
- """
- import io
- import json
- import unittest
- from mcp_protocol import McpProtocolHandler
- # ---------------------------------------------------------------------------
- # _render_text 的 fallback 路径
- # ---------------------------------------------------------------------------
- class RenderTextFallbackTest(unittest.TestCase):
- def test_empty_dict_returns_ok(self):
- self.assertEqual('ok', McpProtocolHandler._render_text({}))
- def test_none_returns_ok(self):
- self.assertEqual('ok', McpProtocolHandler._render_text(None))
- def test_empty_list_returns_ok(self):
- # non-dict → 走 "not structured_content" 分支
- self.assertEqual('ok', McpProtocolHandler._render_text([]))
- def test_dict_without_columns_or_track_fields_falls_back_to_json_dumps(self):
- """有 records 但第一条没有 status/content/time → 走 json.dumps fallback。"""
- content = {'records': [{'order_no': 'SO001', 'qty': 10}]}
- result = McpProtocolHandler._render_text(content)
- # 应该是 json.dumps 的输出,包含键名
- self.assertIn('order_no', result)
- self.assertIn('SO001', result)
- def test_dict_with_no_records_falls_back_to_json_dumps(self):
- """有 columns 但 records 是 None(不是 list)→ columns 是 list 但 records 不是 → fallback。"""
- content = {'columns': [{'key': 'no', 'name': '编号'}], 'records': None}
- result = McpProtocolHandler._render_text(content)
- self.assertIn('columns', result)
- def test_plain_dict_no_columns_no_records_falls_back_to_json(self):
- content = {'foo': 'bar', 'count': 42}
- result = McpProtocolHandler._render_text(content)
- self.assertIn('foo', result)
- self.assertIn('bar', result)
- def test_records_empty_list_no_columns_falls_back_to_json(self):
- """records 是空 list(falsy),columns 是 None → 第二个条件 `records` falsy → fallback。"""
- content = {'records': []}
- result = McpProtocolHandler._render_text(content)
- # json.dumps 输出
- self.assertIn('records', result)
- # ---------------------------------------------------------------------------
- # _render_table_like_text 的 tips / 空 records 路径
- # ---------------------------------------------------------------------------
- class RenderTableEmptyRecordsTest(unittest.TestCase):
- def test_empty_records_with_tips_shows_tips(self):
- content = {
- 'columns': [{'key': 'no', 'name': '编号'}, {'key': 'name', 'name': '名称'}],
- 'records': [],
- 'tips': ['暂无数据', '请调整关键词'],
- }
- result = McpProtocolHandler._render_text(content)
- self.assertIn('表头共 2 列:', result)
- self.assertIn('1. 编号 (no)', result)
- self.assertIn('提示:暂无数据;请调整关键词', result)
- def test_records_with_none_value_renders_empty_string(self):
- content = {
- 'columns': [{'key': 'k', 'name': '键'}],
- 'records': [{'k': None}],
- }
- result = McpProtocolHandler._render_text(content)
- self.assertIn('- 键:', result)
- # ---------------------------------------------------------------------------
- # _render_track_like_text 的可选字段
- # ---------------------------------------------------------------------------
- class RenderTrackOptionalFieldsTest(unittest.TestCase):
- """每个可选字段(location / tracking_number / shipment_id / sub_track)单独覆盖。"""
- def _call(self, **extra):
- record = {'status': '清关放行', 'content': '启运港放行', 'time': '2026-07-07 10:00:00'}
- record.update(extra)
- content = {'records': [record]}
- return McpProtocolHandler._render_text(content)
- def test_location_rendered_when_present(self):
- result = self._call(location='宁波港')
- self.assertIn('- 地点: 宁波港', result)
- def test_location_omitted_when_empty(self):
- result = self._call(location='')
- self.assertNotIn('地点', result)
- def test_location_omitted_when_absent(self):
- result = self._call()
- self.assertNotIn('地点', result)
- def test_tracking_number_rendered_when_present(self):
- result = self._call(tracking_number='TN1234567890')
- self.assertIn('- 快递单号: TN1234567890', result)
- def test_tracking_number_omitted_when_empty(self):
- result = self._call(tracking_number='')
- self.assertNotIn('快递单号', result)
- def test_shipment_id_rendered_when_present(self):
- result = self._call(shipment_id='SHP-9900')
- self.assertIn('- Shipment ID: SHP-9900', result)
- def test_shipment_id_omitted_when_empty(self):
- result = self._call(shipment_id='')
- self.assertNotIn('Shipment ID', result)
- def test_sub_track_rendered_when_truthy(self):
- result = self._call(sub_track=1)
- self.assertIn('- 子单轨迹: 是', result)
- def test_sub_track_omitted_when_zero(self):
- result = self._call(sub_track=0)
- self.assertNotIn('子单轨迹', result)
- def test_sub_track_omitted_when_absent(self):
- result = self._call()
- self.assertNotIn('子单轨迹', result)
- def test_all_optional_fields_together(self):
- result = self._call(
- location='上海港',
- tracking_number='YT123',
- shipment_id='SHP001',
- sub_track=2,
- )
- self.assertIn('地点: 上海港', result)
- self.assertIn('快递单号: YT123', result)
- self.assertIn('Shipment ID: SHP001', result)
- self.assertIn('子单轨迹: 是', result)
- def test_track_with_summary_and_tips(self):
- content = {
- 'summary': '共1条轨迹',
- 'records': [
- {'status': '已签收', 'content': '已签收', 'time': '2026-07-08 12:00:00'}
- ],
- 'tips': ['仅展示最新轨迹'],
- }
- result = McpProtocolHandler._render_text(content)
- self.assertIn('共1条轨迹', result)
- self.assertIn('已签收', result)
- self.assertIn('提示:仅展示最新轨迹', result)
- def test_multiple_track_records(self):
- content = {
- 'records': [
- {'status': '已发货', 'content': '揽件', 'time': '2026-07-06 09:00:00'},
- {'status': '已签收', 'content': '签收', 'time': '2026-07-08 12:00:00'},
- ],
- }
- result = McpProtocolHandler._render_text(content)
- self.assertIn('轨迹 1:', result)
- self.assertIn('轨迹 2:', result)
- self.assertIn('已发货', result)
- self.assertIn('已签收', result)
- # ---------------------------------------------------------------------------
- # run_stdio — parse error 分支
- # ---------------------------------------------------------------------------
- class RunStdioParseErrorTest(unittest.TestCase):
- def test_invalid_json_emits_parse_error_response(self):
- """无效 JSON 行 → 写出 -32700 Parse error 响应。"""
- handler = McpProtocolHandler(None)
- stdin = io.StringIO('not-valid-json\n')
- stdout = io.StringIO()
- handler.run_stdio(stdin=stdin, stdout=stdout)
- line = stdout.getvalue().strip()
- self.assertTrue(line, 'expected a response line on parse error')
- response = json.loads(line)
- self.assertIn('error', response)
- self.assertEqual(-32700, response['error']['code'])
- self.assertEqual('Parse error', response['error']['message'])
- self.assertIsNone(response['id'])
- def test_blank_lines_are_skipped(self):
- handler = McpProtocolHandler(None)
- stdin = io.StringIO('\n \n\t\n')
- stdout = io.StringIO()
- handler.run_stdio(stdin=stdin, stdout=stdout)
- self.assertEqual('', stdout.getvalue().strip())
- def test_mixed_valid_and_invalid_lines(self):
- """有效请求和无效行混合;两者都产生输出。"""
- handler = McpProtocolHandler(None)
- stdin = io.StringIO(
- 'bad-json\n'
- + json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {}})
- + '\n'
- )
- stdout = io.StringIO()
- # initialize 需要 gateway_app,这里简单 patch
- from unittest.mock import MagicMock
- handler.gateway_app = MagicMock()
- handler.gateway_app.list_tools.return_value = []
- handler.run_stdio(stdin=stdin, stdout=stdout)
- lines = [l for l in stdout.getvalue().splitlines() if l.strip()]
- # parse error + initialize response = 2 lines
- self.assertEqual(2, len(lines))
- parse_err = json.loads(lines[0])
- self.assertEqual(-32700, parse_err['error']['code'])
- # ---------------------------------------------------------------------------
- # handle_message / handle_request 边界情况
- # ---------------------------------------------------------------------------
- class HandleMessageEdgeCasesTest(unittest.TestCase):
- def test_non_dict_message_returns_invalid_request_error(self):
- handler = McpProtocolHandler(None)
- response = handler.handle_message('not a dict')
- self.assertEqual(-32600, response['error']['code'])
- def test_notification_with_unknown_method_returns_none(self):
- handler = McpProtocolHandler(None)
- # 无 id → 通知,非 notifications/initialized → returns None
- response = handler.handle_message({'jsonrpc': '2.0', 'method': 'some/notification'})
- self.assertIsNone(response)
- def test_unknown_request_method_returns_method_not_found(self):
- handler = McpProtocolHandler(None)
- response = handler.handle_request({'id': 1, 'method': 'unknown/method'})
- self.assertEqual(-32601, response['error']['code'])
- self.assertIn('Method not found', response['error']['message'])
- def test_non_tools_call_exception_returns_error(self):
- """tools/list 抛异常 → 返回 error 响应(非 isError 内容)。"""
- from unittest.mock import MagicMock
- handler = McpProtocolHandler(MagicMock())
- handler.gateway_app.list_tools.side_effect = RuntimeError('backend error')
- response = handler.handle_request({'id': 5, 'method': 'tools/list'})
- self.assertIn('error', response)
- self.assertIn('backend error', response['error']['message'])
- def test_tools_call_exception_returns_is_error_content(self):
- """tools/call 抛异常 → 返回 isError: true 的 content。"""
- from unittest.mock import MagicMock
- handler = McpProtocolHandler(MagicMock())
- handler.gateway_app.call_tool.side_effect = RuntimeError('tool failed')
- response = handler.handle_request({
- 'id': 6,
- 'method': 'tools/call',
- 'params': {'name': 'query_order', 'arguments': {}},
- })
- self.assertEqual(False, 'error' in response)
- self.assertTrue(response['result']['isError'])
- self.assertIn('tool failed', response['result']['content'][0]['text'])
- if __name__ == '__main__':
- unittest.main()
|