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.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user