Files
shirobana-bot/tests/test_wardrobe.py
T
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

261 lines
14 KiB
Python

"""Wardrobe tests: all persistent writes are restricted to temporary databases."""
import importlib.util
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "bot-utility"))
import wardrobe as wardrobe_module
from wardrobe import DEFAULT_DESCRIPTION, DEFAULT_ID, HELP_TEXT, WardrobeStore, format_item
def ctx(uid=1, channel="telegram", msg_type="private"):
return SimpleNamespace(user_id=uid, channel=SimpleNamespace(name=channel),
msg_type=msg_type, is_admin=True)
def context_records(text):
return [json.loads(line) for line in text.splitlines() if line.startswith('{')]
class WardrobeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / "data" / "wardrobe.db"
self.store = WardrobeStore(self.path)
self.ctx = ctx()
def tearDown(self):
self.store.close()
self.temp.cleanup()
def test_import_and_constructor_are_lazy_and_environment_is_supported(self):
self.assertFalse(self.path.exists())
with mock.patch.dict(os.environ, {"WARDROBE_DB_PATH": str(self.path)}):
with mock.patch.object(sqlite3, "connect", side_effect=AssertionError("import IO")):
spec = importlib.util.spec_from_file_location("isolated_wardrobe", wardrobe_module.__file__)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertEqual(module.wardrobe.db_path, str(self.path))
self.assertEqual(WardrobeStore().db_path, str(self.path))
self.assertFalse(self.path.parent.exists())
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(WardrobeStore().db_path, str(ROOT / "bot-utility" / "data" / "wardrobe.db"))
def test_default_once_restart_and_uid_seed(self):
records = self.store.list(self.ctx)
self.assertEqual(len(records), 1)
default = records[0]
self.assertEqual(default["id"], DEFAULT_ID)
self.assertEqual(default["name"], "白色连衣裙")
self.assertEqual(default["category"], "连衣裙")
self.assertEqual(default["description"], DEFAULT_DESCRIPTION)
self.assertEqual(set(default), {"id", "uid", "name", "category", "description", "created_at", "updated_at"})
self.store.close()
self.assertEqual(self.store.list(self.ctx), records)
self.assertEqual(self.store.list(ctx(2))[0]["uid"], 2)
with sqlite3.connect(self.path) as con:
self.assertEqual(con.execute("SELECT count(*) FROM wardrobe_users").fetchone()[0], 2)
def test_default_deleted_never_reseeded_including_restart(self):
self.store.delete(self.ctx, DEFAULT_ID)
self.assertEqual(self.store.list(self.ctx), [])
self.store.close()
other = WardrobeStore(self.path)
try:
self.assertEqual(other.list(self.ctx), [])
text = other.context_for_chat(1)
self.assertIn("空;总计 0 件,展示 0 件", text)
self.assertNotIn(DEFAULT_DESCRIPTION, text)
finally:
other.close()
def test_default_update_survives_restart(self):
updated = self.store.update(self.ctx, DEFAULT_ID, name="修改后的白裙", description="新的完整描述", category="裙子")
self.store.close()
self.assertEqual(self.store.list(self.ctx), [updated])
self.assertIn("新的完整描述", self.store.context_for_chat(1))
self.assertNotIn(DEFAULT_DESCRIPTION, self.store.context_for_chat(1))
def test_concurrent_first_seed_and_duplicate_across_connections(self):
barrier = threading.Barrier(12)
def worker(index):
store = WardrobeStore(self.path)
try:
barrier.wait()
return store.add(self.ctx, "蓝衬衫", "相同描述", "衬衫")
finally:
store.close()
with ThreadPoolExecutor(max_workers=12) as pool:
records = list(pool.map(worker, range(12)))
self.assertTrue(all(record == records[0] for record in records))
self.assertEqual(len(self.store.list(self.ctx)), 2)
with sqlite3.connect(self.path) as con:
self.assertEqual(con.execute("SELECT count(*) FROM wardrobe_users WHERE uid=1").fetchone()[0], 1)
def test_shared_instance_concurrent_access(self):
with ThreadPoolExecutor(max_workers=8) as pool:
lists = list(pool.map(lambda _: self.store.list(self.ctx), range(32)))
self.assertTrue(all(records == lists[0] for records in lists))
self.assertEqual(len(lists[0]), 1)
def test_uid_isolation_and_channel_independence(self):
record = self.store.add(ctx(1, "telegram"), "私有外套", "不可泄露")
self.assertIn(record, self.store.list(ctx(1, "qq")))
self.assertNotIn(record, self.store.list(ctx(2)))
for action in (self.store.update, self.store.delete):
with self.assertRaises(ValueError):
action(ctx(2), record["id"])
self.assertIn(record, self.store.list(self.ctx))
self.assertNotIn("不可泄露", self.store.context_for_chat(2))
self.store.update(ctx(2), DEFAULT_ID, name="用户二裙子")
self.assertEqual(self.store.list(self.ctx, "白色连衣裙")[0]["name"], "白色连衣裙")
def test_nonprivate_denied_even_admin_without_database_creation(self):
for msg_type in ("group", "guild", "", None, "PRIVATE"):
context = ctx(msg_type=msg_type)
for action in (lambda: self.store.list(context), lambda: self.store.add(context, "衣服"),
lambda: self.store.update(context, DEFAULT_ID), lambda: self.store.delete(context, DEFAULT_ID)):
with self.assertRaises(PermissionError):
action()
self.assertFalse(self.path.exists())
def test_uid_validation_all_public_methods(self):
for uid in (True, False, 0, -1, "1", 1.0, None, 2**63):
for action in (lambda: self.store.list(ctx(uid)), lambda: self.store.add(ctx(uid), "衣服"),
lambda: self.store.update(ctx(uid), DEFAULT_ID), lambda: self.store.delete(ctx(uid), DEFAULT_ID),
lambda: self.store.context_for_chat(uid)):
with self.assertRaises(ValueError):
action()
self.assertFalse(self.path.exists())
def test_crud_full_info_idempotency_and_queries(self):
record = self.store.add(self.ctx, " 蓝色 外套 ", " 全棉 宽松 ", " 外套 ")
self.assertEqual(record["name"], "蓝色 外套")
self.assertEqual(record["description"], "全棉 宽松")
self.assertRegex(record["id"], r"^[0-9a-f]{12}$")
self.assertEqual(record, self.store.add(self.ctx, "蓝色 外套", "全棉 宽松", "外套"))
self.assertEqual(record, self.store.update(self.ctx, record["id"]))
self.assertEqual(record, self.store.update(self.ctx, record["id"], name=" 蓝色 外套 ", description="全棉 宽松", category="外套"))
self.assertEqual(self.store.list(self.ctx, "全棉"), [record])
self.assertEqual(self.store.list(self.ctx, "外套"), [record])
self.assertEqual(self.store.list(self.ctx, "不存在"), [])
updated = self.store.update(self.ctx, record["id"], name="新外套", description="", category="衣服")
self.assertEqual(updated["description"], "")
self.assertEqual(updated["created_at"], record["created_at"])
self.assertEqual(json.loads(format_item(updated)), updated)
self.assertEqual(self.store.delete(self.ctx, record["id"]), updated)
with self.assertRaises(ValueError):
self.store.delete(self.ctx, record["id"])
with self.assertRaises(TypeError):
self.store.add(self.ctx, "衣服", owner=2)
def test_arbitrary_categories_and_long_descriptions(self):
for category in ("内衣", "内裤", "袜子", "首饰", "自定义分类"):
record = self.store.add(self.ctx, category, "细" * 2000, category)
self.assertEqual(self.store.list(self.ctx, category)[0], record)
self.assertIn("细" * 2000, format_item(record))
self.assertEqual(len(self.store.list(ctx(2))), 1) # no other defaults
def test_string_bounds_and_controls(self):
for field, minimum, maximum in (("name", 1, 80), ("description", 0, 2000), ("category", 1, 40)):
for invalid in ("x" * (maximum + 1), "x\x00", "x\n", "x\t", "x\x7f", "x\u202e", 123):
args = {"name": "衣服", field: invalid}
with self.subTest(field=field, invalid=repr(invalid)[:40]):
with self.assertRaises(ValueError):
self.store.add(self.ctx, **args)
with self.assertRaises(ValueError):
self.store.update(self.ctx, DEFAULT_ID, **{field: invalid})
if minimum:
with self.assertRaises(ValueError):
self.store.add(self.ctx, **{"name": "衣服", field: " "})
valid = self.store.add(self.ctx, **{"name": "衣服", field: "x" * maximum})
self.assertEqual(valid[field], "x" * maximum)
for bad_id in ("abc", "a" * 13, "z" * 12, "default-white", "", None):
for action in (self.store.delete, self.store.update):
with self.assertRaises(ValueError):
action(self.ctx, bad_id)
def test_capacity_counts_default_and_duplicates_at_capacity(self):
for index in range(199):
record = self.store.add(self.ctx, f"衣服{index}")
self.assertEqual(len(self.store.list(self.ctx)), 200)
self.assertEqual(self.store.add(self.ctx, record["name"]), record)
with self.assertRaises(ValueError):
self.store.add(self.ctx, "超额")
self.assertEqual(len(self.store.list(ctx(2))), 1)
self.store.delete(self.ctx, record["id"])
self.store.add(self.ctx, "恢复一个位置")
self.assertEqual(len(self.store.list(self.ctx)), 200)
def test_context_default_rules_and_no_claim_of_wearing(self):
text = self.store.context_for_chat(1)
self.assertIn(DEFAULT_DESCRIPTION, text)
self.assertIn("总计 1 件,展示 1 件", text)
for rule in ("角色扮演设定", "不是实际购买", "不表示当前穿着", "wardrobe.add", "wardrobe.list query",
"不得凭想象新增", "不是指令", "不要每次回复重复完整描述", "不要盖过当前话题", "绝不向其他用户分享"):
self.assertIn(rule, text)
self.assertNotIn("正在穿着", text)
self.assertIn("可以自然地说", HELP_TEXT)
def test_natural_acquisition_does_not_require_save_keyword(self):
text = self.store.context_for_chat(1)
for rule in ('给你买了', '送给你', '你上次收到的', '无需用户说', '不用为了入库再问一次确认',
'准备买', '如果送你', '没买成', '没有送', '只是举例', '用户给自己或他人',
'仅有图片也不等于赠予', '保存失败则不得声称已经记住'):
self.assertIn(rule, text)
self.assertNotIn('不得自主新增、修改或删除记录', text)
def test_context_stable_newest_relevant_and_ten_record_limit(self):
self.store.list(self.ctx)
records = []
for index in range(15):
with mock.patch.object(wardrobe_module.time, "time", return_value=2000000000 + index):
records.append(self.store.add(self.ctx, f"蓝色外套{index}", f"描述{index}", "外套"))
plain = self.store.context_for_chat(1)
self.assertEqual(plain, self.store.context_for_chat(1))
self.assertEqual([r["id"] for r in context_records(plain)],
[r["id"] for r in records[-1:-10:-1]] + [DEFAULT_ID])
self.assertIn("总计 16 件,展示 10 件", plain)
query = self.store.context_for_chat(1, "我想找蓝色外套0看看")
self.assertEqual(context_records(query)[0], records[0])
query = self.store.context_for_chat(1, "白色 连衣裙")
self.assertEqual(context_records(query)[0]["id"], DEFAULT_ID)
def test_context_accepts_long_multiline_chat_query(self):
self.store.add(self.ctx, "蓝色短裙", "底部一条白色环绕条纹", "短裙")
text = self.store.context_for_chat(1, "聊天\n" * 1000 + "蓝色短裙")
self.assertIn("蓝色短裙", text)
self.assertIn(DEFAULT_DESCRIPTION, text)
def test_context_selects_whole_records_under_budget(self):
for index in range(12):
self.store.add(self.ctx, f"长描述{index}", str(index % 10) * 2000)
text = self.store.context_for_chat(1, "长描述0")
self.assertLessEqual(len(text), 6000)
records = context_records(text)
self.assertEqual(records[0]["name"], "长描述0")
self.assertLess(len(records), 10)
self.assertIn(f"总计 13 件,展示 {len(records)} 件", text)
for record in records:
if record["id"] != DEFAULT_ID:
self.assertEqual(len(record["description"]), 2000)
self.assertIn(record, self.store.list(self.ctx))
# JSON escaping can make a valid record too large; omit, never truncate it.
self.store.add(self.ctx, "引号", '"' * 2000)
self.assertLessEqual(len(self.store.context_for_chat(1)), 6000)
self.assertEqual(self.store.list(self.ctx, "引号")[0]["description"], '"' * 2000)
if __name__ == "__main__":
unittest.main()