417 lines
22 KiB
Python
417 lines
22 KiB
Python
"""今日记录、午夜清理、完整分批总结与清空竞态;不连接微信或外部 API。"""
|
|
import json
|
|
import logging
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
from plugins.chat_records import Plugin as ClearPlugin
|
|
from plugins.chat_summary import Plugin as SummaryPlugin
|
|
from wxbot_framework.config import ConfigStore
|
|
from wxbot_framework.llm import LLMError
|
|
from wxbot_framework.members import MemberStore, SnapshotExpired
|
|
from wxbot_framework.runtime import BotApplication
|
|
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
|
|
from wxbot_framework.summary import ChatSummarizer, conversation_rows, split_text
|
|
from wxbot_framework.text import TextRouter
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ROOM, OTHER_ROOM = "123@chatroom", "456@chatroom"
|
|
ROBOT, CAT, OTHER = "wxid_robot", "amy123", "wxid_other"
|
|
|
|
|
|
class Backend:
|
|
session_id = "test"
|
|
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
def send_text(self, room, text):
|
|
self.sent.append((room, text))
|
|
return True
|
|
|
|
def stop(self):
|
|
return True
|
|
|
|
|
|
class DailySummaryTests(unittest.TestCase):
|
|
def setUp(self):
|
|
scratch = ROOT / ".runtime/tests"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
self.temporary = tempfile.TemporaryDirectory(dir=scratch)
|
|
self.root = Path(self.temporary.name)
|
|
self.now = datetime(2026, 10, 10, 15, 59, 59, tzinfo=timezone.utc) # 北京 23:59:59
|
|
options = {"database_file": "data.sqlite3", "history_enabled": True, "daily_clear": True,
|
|
"groups": {ROOM: [], OTHER_ROOM: []}}
|
|
self.path = self.root / "members.json"
|
|
self.path.write_text(json.dumps(options), encoding="utf-8")
|
|
self.stores = []
|
|
self.store = self.new_store()
|
|
self.store.register("测试成员", ROOM, CAT)
|
|
self.store.register("别人", ROOM, OTHER)
|
|
self.store.register("测试成员", OTHER_ROOM, CAT)
|
|
self.backend = Backend()
|
|
self.service = ServiceFacade(text_delay={"enabled": False})
|
|
self.service.bind(self.backend)
|
|
self.ctx = PluginContext("summary", {}, self.service, [], member_store=self.store)
|
|
self.calls = []
|
|
self.ctx._llm = Mock(complete_json=self.model)
|
|
self.plugin = SummaryPlugin()
|
|
self.plugin.on_load(self.ctx)
|
|
self.clear_plugin = ClearPlugin()
|
|
self.clear_plugin.on_load(self.ctx)
|
|
|
|
def tearDown(self):
|
|
self.service.close()
|
|
for store in self.stores:
|
|
store.close()
|
|
self.assertTrue(self.root.resolve().is_relative_to((ROOT / ".runtime/tests").resolve()))
|
|
self.temporary.cleanup()
|
|
|
|
def new_store(self, account=ROBOT):
|
|
store = MemberStore(self.path, account, now=lambda: self.now)
|
|
self.stores.append(store)
|
|
store.initialize()
|
|
return store
|
|
|
|
def message(self, text, sender=CAT, room=ROOM, sequence=0, **kwargs):
|
|
return Message(text, room, sender_id=sender, is_group=True, session_id="test", sequence=sequence, **kwargs)
|
|
|
|
def model(self, system, user):
|
|
self.calls.append(json.loads(user))
|
|
return {"summary": "讨论了晚餐和明天的安排。"}
|
|
|
|
def snapshot(self):
|
|
return self.store.today_snapshot("测试成员", ROOM)
|
|
|
|
def test_midnight_clears_idle_buffer_preserves_identity_and_invalidates_snapshot(self):
|
|
self.store.record(self.message("今晚聊聊天"))
|
|
snapshot = self.snapshot()
|
|
self.assertEqual(snapshot.day, "2026-10-10")
|
|
self.now += timedelta(seconds=1)
|
|
self.assertTrue(self.store.rollover())
|
|
self.assertEqual(self.store.history("测试成员", ROOM), [])
|
|
self.assertFalse(self.store.snapshot_is_current(snapshot))
|
|
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
|
|
self.store.record(self.message("新的一天"))
|
|
self.assertEqual([r["text"] for r in self.snapshot().messages], ["新的一天"])
|
|
self.assertEqual(self.snapshot().day, "2026-10-11")
|
|
|
|
def test_restart_after_missed_midnight_cleans_previous_day(self):
|
|
self.store.record(self.message("旧记录"))
|
|
self.store.close()
|
|
self.now += timedelta(days=2)
|
|
reopened = self.new_store()
|
|
self.assertEqual(reopened.today_snapshot("测试成员", ROOM).messages, ())
|
|
self.assertEqual(reopened.history("测试成员", ROOM), [])
|
|
|
|
def test_restart_same_day_retains_all_text_and_aliases(self):
|
|
self.store.record(self.message("今天的话"))
|
|
self.store.register("测试成员新名", ROOM, CAT)
|
|
reopened = self.new_store()
|
|
self.assertEqual(reopened.resolve("测试成员", ROOM).name, "测试成员新名")
|
|
self.assertEqual(len(reopened.today_snapshot("测试成员新名", ROOM).messages), 1)
|
|
|
|
def test_clear_isolates_groups_and_accounts_without_deleting_bindings(self):
|
|
self.store.record(self.message("这个群"))
|
|
self.store.record(self.message("另一个群", room=OTHER_ROOM))
|
|
other = self.new_store("wxid_second_robot")
|
|
other.register("测试成员", ROOM, CAT)
|
|
other.record(self.message("另一个账号"))
|
|
snapshot = self.snapshot()
|
|
self.assertEqual(self.store.clear(ROOM), 1)
|
|
self.assertFalse(self.store.snapshot_is_current(snapshot))
|
|
self.assertEqual(len(self.store.today_snapshot("测试成员", OTHER_ROOM).messages), 1)
|
|
self.assertEqual(len(other.today_snapshot("测试成员", ROOM).messages), 1)
|
|
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
|
|
|
|
def test_today_snapshot_does_not_truncate_at_console_history_limit(self):
|
|
for index in range(1005):
|
|
self.store.record(self.message(str(index), sequence=index + 1))
|
|
self.assertEqual(len(self.snapshot().messages), 1005)
|
|
self.assertEqual(self.snapshot().messages[0]["text"], "0")
|
|
self.assertEqual(len(self.store.today_group_snapshot(ROOM).messages), 1005)
|
|
|
|
def test_bot_output_stored_but_excluded_from_member_summary(self):
|
|
self.store.record(self.message("机器人输出", data={"bot_generated": True}))
|
|
self.store.record(self.message("自己手机的话"))
|
|
self.assertEqual(len(self.store.history("测试成员", ROOM)), 2)
|
|
self.assertEqual([r["text"] for r in self.snapshot().messages], ["自己手机的话"])
|
|
|
|
def test_summary_uses_current_room_and_actual_id_and_filters_commands(self):
|
|
for text in ("晚饭吃面", "/绑定 测试成员", "/总结 测试成员", "/总结群聊", "/help"):
|
|
self.store.record(self.message(text))
|
|
self.store.record(self.message("别人的话", sender=OTHER))
|
|
self.store.record(self.message("另一个群的话", room=OTHER_ROOM))
|
|
self.plugin.on_message(self.message("/总结 测试成员", sender=OTHER), self.ctx)
|
|
self.assertEqual(len(self.calls), 1)
|
|
data = self.calls[0]
|
|
self.assertEqual([r["text"] for r in data["data"]], ["晚饭吃面"])
|
|
self.assertNotIn(CAT, json.dumps(data))
|
|
self.assertIn("1条", self.backend.sent[-1][1])
|
|
|
|
def test_unknown_member_and_no_chat_do_not_call_model(self):
|
|
self.plugin.on_message(self.message("/总结 未绑定"), self.ctx)
|
|
self.assertIn("请本人先发送 /绑定", self.backend.sent[-1][1])
|
|
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
|
|
self.assertIn("没有可总结", self.backend.sent[-1][1])
|
|
self.assertEqual(self.calls, [])
|
|
|
|
def test_summary_and_clear_validate_commands_and_ignore_private_chats(self):
|
|
self.plugin.on_message(self.message("/总结"), self.ctx)
|
|
self.assertEqual(self.backend.sent[-1][1], "用法:/总结 昵称")
|
|
self.clear_plugin.on_message(self.message("/清空 别人"), self.ctx)
|
|
self.assertEqual(self.backend.sent[-1][1], "用法:/清空")
|
|
count = len(self.backend.sent)
|
|
self.plugin.on_message(Message("/总结 测试成员", CAT), self.ctx)
|
|
self.clear_plugin.on_message(Message("/清空", CAT), self.ctx)
|
|
self.assertEqual(len(self.backend.sent), count)
|
|
|
|
def test_manual_clear_command_keeps_member_registration(self):
|
|
self.store.record(self.message("可清空的记录"))
|
|
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
|
|
self.assertEqual(self.snapshot().messages, ())
|
|
self.assertEqual(self.backend.sent[-1][1], "已清空本群聊天记录。")
|
|
self.assertEqual(self.store.resolve("测试成员", ROOM).wxid, CAT)
|
|
|
|
def test_all_long_records_are_covered_and_partials_are_merged(self):
|
|
texts = [f"第{i}条" + "内容" * 950 for i in range(9)]
|
|
for text in texts:
|
|
self.store.record(self.message(text))
|
|
summary = ChatSummarizer(self.ctx.llm, chunk_chars=4000)
|
|
snapshot = self.snapshot()
|
|
summary.summarize(snapshot, conversation_rows(snapshot), current=lambda: True)
|
|
received = [item for call in self.calls for item in call["data"] if "text" in item]
|
|
for index, original in enumerate(texts, 1):
|
|
self.assertEqual("".join(item["text"] for item in received if item["record"] == index), original)
|
|
self.assertTrue(any(call["task"] == "合并为最终总结" for call in self.calls))
|
|
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
|
|
|
|
def test_clear_during_llm_call_cancels_result(self):
|
|
self.store.record(self.message("不能清空后再发"))
|
|
def clear_during_request(system, user):
|
|
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
|
|
return {"summary": "旧总结"}
|
|
self.ctx._llm = Mock(complete_json=clear_during_request)
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
|
|
self.assertEqual(self.backend.sent, [(ROOM, "已清空本群聊天记录。")])
|
|
|
|
def test_json_escaping_does_not_overflow_small_batches(self):
|
|
text = "\x01" * 2000
|
|
self.store.record(self.message(text))
|
|
snapshot = self.snapshot()
|
|
ChatSummarizer(self.ctx.llm, chunk_chars=4000).summarize(
|
|
snapshot, conversation_rows(snapshot), current=lambda: True)
|
|
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
|
|
units = [unit for call in self.calls for unit in call["data"] if "text" in unit]
|
|
self.assertEqual("".join(unit["text"] for unit in units), text)
|
|
|
|
def test_midnight_during_request_cancels_result(self):
|
|
self.store.record(self.message("旧天聊天"))
|
|
def midnight(system, user):
|
|
self.now += timedelta(seconds=1)
|
|
return {"summary": "昨天的总结"}
|
|
self.ctx._llm = Mock(complete_json=midnight)
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
|
|
self.assertEqual(self.backend.sent, [])
|
|
|
|
def test_clear_during_pacing_prevents_native_send(self):
|
|
self.store.record(self.message("旧记录"))
|
|
snapshot = self.snapshot()
|
|
cancel = Mock(is_set=lambda: False)
|
|
def wait(seconds):
|
|
self.store.clear(ROOM)
|
|
return False
|
|
cancel.wait = wait
|
|
self.service._cancel = cancel
|
|
with self.assertRaises(SnapshotExpired):
|
|
self.ctx.reply(self.message("/总结 测试成员"), "待发送的旧总结",
|
|
delivery_guard=lambda: self.store.delivery_guard(snapshot))
|
|
self.assertEqual(self.backend.sent, [])
|
|
|
|
def test_new_messages_do_not_cancel_existing_snapshot(self):
|
|
snapshot = self.snapshot()
|
|
self.store.record(self.message("新消息"))
|
|
self.assertTrue(self.store.snapshot_is_current(snapshot))
|
|
|
|
def test_llm_error_returns_short_message_and_preserves_records(self):
|
|
self.store.record(self.message("原始聊天"))
|
|
self.ctx._llm = Mock(complete_json=Mock(side_effect=LLMError("模拟超时")))
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结 测试成员"), self.ctx)
|
|
self.assertIn("暂时无法生成", self.backend.sent[-1][1])
|
|
self.assertEqual(len(self.snapshot().messages), 1)
|
|
|
|
def test_malformed_summary_is_rejected(self):
|
|
self.store.record(self.message("聊天"))
|
|
snapshot = self.snapshot()
|
|
for result in ({}, {"summary": ""}, {"summary": 1}, {"summary": "a\0b"}, {"summary": "a\x01b"}, {"summary": "字" * 601}, []):
|
|
client = Mock(complete_json=Mock(return_value=result))
|
|
with self.subTest(result_type=type(result)), self.assertRaises(LLMError):
|
|
ChatSummarizer(client).summarize(snapshot, conversation_rows(snapshot), current=lambda: True)
|
|
|
|
def test_utf8_chunks_keep_all_characters_and_fit_native_limit(self):
|
|
text = "中文🌸abc\n" * 300
|
|
parts = split_text(text)
|
|
self.assertEqual("".join(parts), text)
|
|
self.assertTrue(all(0 < len(part.encode("utf-8")) <= 1023 for part in parts))
|
|
|
|
def test_owner_group_messages_are_archived_without_reply_loops(self):
|
|
router = TextRouter(include_self_groups=True)
|
|
router.set_account(ROBOT)
|
|
data = router.convert(ROOM, ROBOT, ROBOT + ":\n普通手机聊天")
|
|
self.assertFalse(data["dispatch_to_plugins"])
|
|
self.store.register("主人", ROOM, ROBOT)
|
|
# 验证 runtime 确实先记下文本,再跳过插件。
|
|
app = object.__new__(BotApplication)
|
|
app.config = {"chat_allowlist": []}
|
|
app.members, app.plugins = self.store, Mock()
|
|
app._emit(Message.from_native(11046, data, "test"))
|
|
app.plugins.dispatch.assert_not_called()
|
|
self.assertEqual(self.store.today_snapshot("主人", ROOM).messages[0]["text"], "普通手机聊天")
|
|
for command in ("/绑定 主人", "/总结 测试成员", "/总结群聊", "/清空"):
|
|
self.assertTrue(router.convert(ROOM, ROBOT, ROBOT + ":\n" + command)["dispatch_to_plugins"])
|
|
|
|
def test_group_snapshot_includes_unbound_and_owner_but_isolates_room_and_account(self):
|
|
self.store.record(self.message("测试成员先说"))
|
|
self.store.record(self.message("未绑定者接话", sender="wxid_unbound"))
|
|
self.store.record(self.message("主人手机说话", sender=ROBOT, is_self=True))
|
|
self.store.record(self.message("测试成员再说"))
|
|
self.store.record(self.message("机器人输出", sender=ROBOT, is_self=True, data={"bot_generated": True}))
|
|
self.store.record(self.message("另一个群", room=OTHER_ROOM))
|
|
other = self.new_store("wxid_second_robot")
|
|
other.record(self.message("另一个账号的记录"))
|
|
snapshot = self.store.today_group_snapshot(ROOM)
|
|
self.assertEqual([row["text"] for row in snapshot.messages],
|
|
["测试成员先说", "未绑定者接话", "主人手机说话", "测试成员再说"])
|
|
self.assertEqual([row["speaker"] for row in snapshot.messages], ["P1", "P2", "P3", "P1"])
|
|
self.assertEqual(snapshot.messages[0]["speaker_name"], "测试成员")
|
|
self.assertEqual(snapshot.messages[1]["speaker_name"], "未绑定成员2")
|
|
self.assertEqual(snapshot.room, ROOM)
|
|
|
|
def test_group_command_sends_all_people_without_raw_ids_and_filters_commands(self):
|
|
self.store.record(self.message("测试成员想吃面"))
|
|
self.store.record(self.message("别人想吃饭", sender=OTHER))
|
|
self.store.record(self.message("未绑定者订餐", sender="wxid_unbound"))
|
|
self.store.record(self.message("/总结群聊", sender=ROBOT, is_self=True))
|
|
self.store.record(self.message("/总结 测试成员"))
|
|
client = Mock(complete_json=Mock(side_effect=self.model))
|
|
self.ctx._llm = client
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结群聊", sender=ROBOT, is_self=True), self.ctx)
|
|
self.assertEqual(self.calls[0]["scope"], "group")
|
|
self.assertEqual([unit["text"] for unit in self.calls[0]["data"]],
|
|
["测试成员想吃面", "别人想吃饭", "未绑定者订餐"])
|
|
self.assertEqual([unit["speaker_name"] for unit in self.calls[0]["data"]],
|
|
["测试成员", "别人", "未绑定成员3"])
|
|
payload = json.dumps(self.calls, ensure_ascii=False)
|
|
for wxid in (ROBOT, CAT, OTHER, "wxid_unbound", ROOM):
|
|
self.assertNotIn(wxid, payload)
|
|
self.assertIn("当前微信群所有人的聊天", client.complete_json.call_args.args[0])
|
|
self.assertIn("今日群聊总结", self.backend.sent[-1][1])
|
|
self.assertIn("3条,3人", self.backend.sent[-1][1])
|
|
|
|
def test_group_command_empty_or_only_commands_does_not_call_model(self):
|
|
for text in ("/绑定 测试成员", "/help", "/总结群聊"):
|
|
self.store.record(self.message(text))
|
|
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
|
|
self.assertEqual(self.calls, [])
|
|
self.assertEqual(self.backend.sent[-1][1], "本群今天还没有可总结的聊天记录。")
|
|
|
|
def test_group_command_requires_exact_command_no_argument_and_group_context(self):
|
|
self.plugin.on_message(self.message("/总结群聊 测试成员"), self.ctx)
|
|
self.assertEqual(self.backend.sent[-1][1], "用法:/总结群聊")
|
|
count = len(self.backend.sent)
|
|
self.plugin.on_message(self.message("/总结群聊测试"), self.ctx)
|
|
self.plugin.on_message(Message("/总结群聊", CAT), self.ctx)
|
|
self.plugin.on_message(self.message("/总结群聊", room="999@chatroom"), self.ctx)
|
|
self.assertEqual(len(self.backend.sent), count)
|
|
self.assertEqual(self.calls, [])
|
|
|
|
def test_group_clear_during_request_cancels_result(self):
|
|
self.store.record(self.message("今天一起吃饭"))
|
|
def clear_during_request(system, user):
|
|
self.clear_plugin.on_message(self.message("/清空"), self.ctx)
|
|
return {"summary": "已被清空的全群聊天"}
|
|
self.ctx._llm = Mock(complete_json=clear_during_request)
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
|
|
self.assertEqual(self.backend.sent, [(ROOM, "已清空本群聊天记录。")])
|
|
|
|
def test_group_midnight_during_request_cancels_result(self):
|
|
self.store.record(self.message("旧日全群聊天"))
|
|
def midnight(system, user):
|
|
self.now += timedelta(seconds=1)
|
|
return {"summary": "昨天的全群总结"}
|
|
self.ctx._llm = Mock(complete_json=midnight)
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
|
|
self.assertEqual(self.backend.sent, [])
|
|
self.assertEqual(self.store.today_group_snapshot(ROOM).messages, ())
|
|
|
|
def test_group_guard_after_send_delay_detects_clear(self):
|
|
self.store.record(self.message("全群快照"))
|
|
snapshot = self.store.today_group_snapshot(ROOM)
|
|
cancel = Mock(is_set=lambda: False)
|
|
def wait(seconds):
|
|
self.store.clear(ROOM)
|
|
return False
|
|
cancel.wait = wait
|
|
self.service._cancel = cancel
|
|
with self.assertRaises(SnapshotExpired):
|
|
self.ctx.reply(self.message("/总结群聊"), "待发送全群总结",
|
|
delivery_guard=lambda: self.store.delivery_guard(snapshot))
|
|
self.assertEqual(self.backend.sent, [])
|
|
|
|
def test_long_group_batches_preserve_speakers_all_text_and_json_size(self):
|
|
self.store.register("\x01" * 64, ROOM, CAT)
|
|
originals = ["\x02" * 2000, "其他人" * 1000, "最后" * 2000]
|
|
for index, text in enumerate(originals):
|
|
self.store.record(self.message(text, sender=CAT if index != 1 else OTHER))
|
|
snapshot = self.store.today_group_snapshot(ROOM)
|
|
ChatSummarizer(self.ctx.llm, chunk_chars=4000).summarize(
|
|
snapshot, conversation_rows(snapshot), current=lambda: True)
|
|
self.assertTrue(all(len(json.dumps(call, ensure_ascii=False)) <= 4000 for call in self.calls))
|
|
units = [unit for call in self.calls for unit in call["data"] if "text" in unit]
|
|
for index, text in enumerate(originals, 1):
|
|
parts = [unit for unit in units if unit["record"] == index]
|
|
self.assertEqual("".join(unit["text"] for unit in parts), text)
|
|
self.assertTrue(all(unit["speaker"] == ("P2" if index == 2 else "P1") for unit in parts))
|
|
self.assertTrue(any(call["task"] == "合并为最终总结" for call in self.calls))
|
|
|
|
def test_group_llm_error_preserves_all_people_records(self):
|
|
self.store.record(self.message("测试成员今天的话"))
|
|
self.store.record(self.message("别人今天的话", sender=OTHER))
|
|
self.ctx._llm = Mock(complete_json=Mock(side_effect=LLMError("模拟群聊总结失败")))
|
|
self.plugin.on_load(self.ctx)
|
|
self.plugin.on_message(self.message("/总结群聊"), self.ctx)
|
|
self.assertIn("暂时无法生成", self.backend.sent[-1][1])
|
|
self.assertEqual(len(self.store.today_group_snapshot(ROOM).messages), 2)
|
|
|
|
def test_runtime_rollover_runs_without_new_messages(self):
|
|
# 禁用连接和所有插件,只验证主循环的定时清理调用。
|
|
config = json.loads((ROOT / "bot_config.json").read_text(encoding="utf-8"))
|
|
config["members_config_file"] = "members.json"
|
|
config["wechat_account"] = ROBOT
|
|
config["console"] = False
|
|
for settings in config["plugins"].values():
|
|
settings["enabled"] = False
|
|
config_path = self.root / "bot_config.json"
|
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
|
(self.root / "plugins").mkdir()
|
|
app = BotApplication(self.root, ConfigStore(config_path))
|
|
app.members.close()
|
|
app.members = Mock()
|
|
app._connect = Mock()
|
|
app.run(seconds=0.65)
|
|
self.assertGreaterEqual(app.members.rollover.call_count, 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|