mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-02 18:50:08 +08:00
add # 新增项目整体大纲
This commit is contained in:
+9
-10
@@ -1,12 +1,11 @@
|
||||
# 测试实施边界
|
||||
# 基础核心测试
|
||||
|
||||
当前没有新核心或业务自动化测试,不将空目录算作测试通过。
|
||||
迁移完整性可运行 python scripts/check_baseline.py。
|
||||
- test_domain.py:整数规则、202条目录、身份和错误配置。
|
||||
- test_transactions.py:完整玩法、边界、回滚、幂等、并发、取消及schema。
|
||||
- test_packaging.py:框架隔离、导入副作用、离线wheel安装。
|
||||
|
||||
后续按 docs/01-roadmap.md 和 docs/06-code-review.md 建立:
|
||||
unit(纯规则与数值)、integration(SQLite 事务与并发)、
|
||||
contracts(框架输入输出)、migrations(迁移与恢复)、
|
||||
packaging(独立安装包及真实框架加载)。
|
||||
|
||||
首批必须覆盖旧缺陷 F01–F08/F11 和提交前取消、重复事件、跨日、
|
||||
同一玩家并发操作。测试使用脱敏构造数据,不提交真实玩家数据库。
|
||||
python scripts/verify_foundation.py 为统一离线验证入口。
|
||||
包装测试需本机已有 pip、setuptools、wheel,不访问网络安装依赖。
|
||||
测试只使用带明确前缀的临时目录和构造玩家,不读取真实玩家库。
|
||||
语法基线3.10;本轮执行环境为Windows/Python3.12.14。
|
||||
真实宿主合同、旧数据迁移和Linux运行矩阵尚待后续实现。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from farm_core import Catalogue, FarmError, Identity
|
||||
from farm_core.domain import (
|
||||
MAX_VALUE, Plant, boosted, level_for,
|
||||
)
|
||||
|
||||
|
||||
class DomainTests(unittest.TestCase):
|
||||
def test_integer_level_boundaries(self):
|
||||
for level in (0, 1, 2, 50, 1000000):
|
||||
threshold = 100 * level * (level + 1)
|
||||
self.assertEqual(level_for(threshold), level)
|
||||
if level:
|
||||
self.assertEqual(level_for(threshold - 1), level - 1)
|
||||
self.assertGreater(level_for(MAX_VALUE), 0)
|
||||
|
||||
def test_per_plot_bonus_is_order_independent(self):
|
||||
plots = [(20, 100), (30, 50), (17, 10)]
|
||||
first = sum(boosted(*plot) for plot in plots)
|
||||
second = sum(boosted(*plot) for plot in reversed(plots))
|
||||
self.assertEqual(first, 103)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_bool_and_out_of_range_values_are_not_integers(self):
|
||||
for value in (True, False, -1, 1.5, MAX_VALUE + 1):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(FarmError):
|
||||
level_for(value)
|
||||
|
||||
def test_explicit_identity_encoding_does_not_collide(self):
|
||||
first = Identity("a", "b:c", "d", "e")
|
||||
second = Identity("a:b", "c", "d", "e")
|
||||
self.assertNotEqual(first.key, second.key)
|
||||
with self.assertRaises(FarmError):
|
||||
Identity("a", "", "qq", "1")
|
||||
|
||||
def test_bundled_catalogue_matches_all_source_rows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
document = json.loads(
|
||||
(root / "src/farm_core/data/plants.json").read_bytes()
|
||||
)
|
||||
catalogue = Catalogue.bundled()
|
||||
self.assertEqual(len(catalogue), 202)
|
||||
for row in document["plants"]:
|
||||
expected = Plant.from_legacy(row)
|
||||
self.assertEqual(catalogue.get(row["name"]), expected)
|
||||
self.assertEqual(catalogue.get("胡萝卜").growth_seconds, 13 * 3600)
|
||||
|
||||
def test_corrupt_catalogue_is_rejected(self):
|
||||
for content in (
|
||||
b"not json", b"{}", b'{"schema":true,"plants":[]}',
|
||||
b'{"schema":2,"plants":[]}',
|
||||
):
|
||||
with self.subTest(content=content):
|
||||
with self.assertRaises(FarmError):
|
||||
Catalogue.loads(content)
|
||||
|
||||
def test_invalid_and_duplicate_plants_are_rejected(self):
|
||||
plant = next(iter(Catalogue.bundled()))
|
||||
with self.assertRaises(FarmError):
|
||||
Catalogue([plant, plant])
|
||||
with self.assertRaises(FarmError):
|
||||
Catalogue.bundled().get("不存在")
|
||||
with self.assertRaises(FarmError):
|
||||
Plant(1, "bad", 0, -1, False, 0, 1, 1, 1, 1, 1, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,147 @@
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
def test_core_has_no_framework_imports_and_uses_python310_syntax(self):
|
||||
for path in (
|
||||
* (ROOT / "src/farm_core").glob("*.py"),
|
||||
* (ROOT / "src/farm_render").glob("*.py"),
|
||||
):
|
||||
self.assertTrue(all(
|
||||
len(line) <= 80
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
), path.name)
|
||||
tree = ast.parse(
|
||||
path.read_text(encoding="utf-8"), feature_version=(3, 10),
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
names = []
|
||||
if isinstance(node, ast.Import):
|
||||
names = [alias.name for alias in node.names]
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 0:
|
||||
names = [node.module or ""]
|
||||
self.assertFalse(
|
||||
any(name.split(".")[0] in (
|
||||
"nonebot", "astrbot", "zhenxun",
|
||||
) for name in names),
|
||||
path.name,
|
||||
)
|
||||
|
||||
def test_import_has_no_runtime_files_or_background_threads(self):
|
||||
code = (
|
||||
"import os,sys,threading; "
|
||||
"before=set(os.listdir('.')); "
|
||||
"threads=len(threading.enumerate()); "
|
||||
"sys.path.insert(0,sys.argv[1]); import farm_core; "
|
||||
"assert set(os.listdir('.'))==before; "
|
||||
"assert len(threading.enumerate())==threads"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="farm-import-test-",
|
||||
) as temporary:
|
||||
subprocess.run(
|
||||
[sys.executable, "-I", "-B", "-c", code, str(ROOT / "src")],
|
||||
cwd=temporary, check=True, timeout=30,
|
||||
)
|
||||
|
||||
def test_wheel_runs_without_source_legacy_or_frameworks(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="farm-wheel-test-",
|
||||
) as temporary:
|
||||
temporary = Path(temporary).resolve()
|
||||
self.assertTrue(temporary.is_relative_to(
|
||||
Path(tempfile.gettempdir()).resolve()
|
||||
))
|
||||
wheels = temporary / "wheels"
|
||||
installed = temporary / "installed"
|
||||
self.run_checked([
|
||||
sys.executable, "-m", "pip", "--disable-pip-version-check",
|
||||
"wheel", "--no-index", "--no-deps", "--no-build-isolation",
|
||||
"--wheel-dir", str(wheels), str(ROOT),
|
||||
])
|
||||
wheel, = wheels.glob("farm_core-*.whl")
|
||||
self.run_checked([
|
||||
sys.executable, "-m", "pip", "--disable-pip-version-check",
|
||||
"install", "--no-index", "--no-deps", "--no-compile",
|
||||
"--target", str(installed), str(wheel),
|
||||
])
|
||||
code = """
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
target = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(target))
|
||||
import farm_core
|
||||
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
||||
from farm_render import FarmRenderer, RenderOptions
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
assert Path(farm_core.__file__).is_relative_to(target)
|
||||
assert len(Catalogue.bundled()) == 202
|
||||
|
||||
async def check():
|
||||
store = SQLiteStore(sys.argv[2])
|
||||
await store.initialize()
|
||||
try:
|
||||
service = FarmService(store, Catalogue.bundled(), lambda: 0)
|
||||
who = Identity("installed", "local", "local", "user")
|
||||
registered = await service.register(who, "installed", "register")
|
||||
assert registered.data["account"]["point"] == 500
|
||||
plant = next(
|
||||
p for p in service.catalogue
|
||||
if p.level == 0 and not p.is_vip and p.cost <= 500
|
||||
)
|
||||
await service.buy(who, plant.name, 1, "buy")
|
||||
assert (await service.snapshot(who)).inventory[0].seeds == 1
|
||||
font = Path(sys.argv[3])
|
||||
renderer = FarmRenderer(font)
|
||||
assert len(renderer.assets.metadata["images"]) > 600
|
||||
result = renderer.render(
|
||||
who, await service.snapshot(who), 0,
|
||||
RenderOptions(format="PNG"),
|
||||
)
|
||||
assert Image.open(BytesIO(result.content)).size == (1024, 624)
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
asyncio.run(check())
|
||||
"""
|
||||
self.run_checked(
|
||||
[
|
||||
sys.executable, "-I", "-B", "-c", code, str(installed),
|
||||
str(temporary / "installed.sqlite3"),
|
||||
str(Path(os.environ.get(
|
||||
"FARM_TEST_FONT", (
|
||||
"C:/Windows/Fonts/msyh.ttc" if os.name == "nt"
|
||||
else "/usr/share/fonts/truetype/"
|
||||
"dejavu/DejaVuSans.ttf"
|
||||
),
|
||||
))),
|
||||
],
|
||||
cwd=temporary,
|
||||
)
|
||||
|
||||
def run_checked(self, command, cwd=ROOT):
|
||||
result = subprocess.run(
|
||||
command, cwd=cwd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=90,
|
||||
env={**os.environ, "PIP_NO_INDEX": "1"},
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode, 0, result.stdout + result.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,296 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, features
|
||||
|
||||
from farm_core import Catalogue, Identity
|
||||
from farm_core.domain import Account, Plot, Snapshot
|
||||
|
||||
try:
|
||||
from farm_render import FarmRenderer, RenderOptions, RenderService
|
||||
from farm_render.assets import ByteLRU
|
||||
from farm_render.canvas import Canvas
|
||||
except ImportError:
|
||||
FarmRenderer = None
|
||||
|
||||
|
||||
FONT = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if not FONT.exists():
|
||||
FONT = Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
|
||||
FONT = Path(os.environ.get("FARM_TEST_FONT", FONT))
|
||||
|
||||
|
||||
def snapshot(plots=None, **account):
|
||||
values = dict(name="演示农场", point=500, vip_point=0, experience=0)
|
||||
values.update(account)
|
||||
return Snapshot(Account(**values), (), tuple(
|
||||
plots if plots is not None else [Plot(i) for i in range(1, 4)]
|
||||
))
|
||||
|
||||
|
||||
class RenderTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(FarmRenderer, "共享图片模块尚未实现")
|
||||
self.renderer = FarmRenderer(FONT)
|
||||
self.who = Identity("test", "local", "qq", "one")
|
||||
self.plant = next(iter(Catalogue.bundled()))
|
||||
|
||||
def test_canvas_alpha_crop_text_and_formats(self):
|
||||
canvas = Canvas.new((100, 80), "white")
|
||||
overlay = Image.new("RGBA", (20, 20), (255, 0, 0, 128))
|
||||
canvas.paste(overlay, (0, 0))
|
||||
self.assertEqual(canvas.image.getpixel((5, 5))[:3], (255, 127, 127))
|
||||
self.assertEqual(canvas.crop((0, 0, 10, 10)).image.size, (10, 10))
|
||||
self.assertEqual(canvas.resize((50, 40)).image.size, (50, 40))
|
||||
text = canvas.fit_text("long farm name" * 20, FONT, 16, 80)
|
||||
self.assertLessEqual(canvas.measure_text(text, FONT, 16), 80)
|
||||
canvas.text((1, 30), text, FONT, 16, "black")
|
||||
for fmt in ("PNG", "JPEG", "WEBP"):
|
||||
if fmt == "WEBP" and not features.check("webp"):
|
||||
continue
|
||||
content = canvas.encode(fmt, 75, 2)
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
self.assertEqual(image.format, fmt)
|
||||
self.assertEqual(image.size, (100, 80))
|
||||
|
||||
def test_profiles_formats_and_bad_avatar_fallback(self):
|
||||
for width in (1024, 1536):
|
||||
for fmt in ("JPEG", "PNG", "WEBP"):
|
||||
if fmt == "WEBP" and not features.check("webp"):
|
||||
continue
|
||||
result = self.renderer.render(
|
||||
self.who, snapshot(), 0,
|
||||
RenderOptions(width=width, format=fmt), b"broken avatar",
|
||||
)
|
||||
with Image.open(BytesIO(result.content)) as image:
|
||||
self.assertEqual(image.width, width)
|
||||
self.assertEqual(image.format, fmt)
|
||||
self.assertEqual(result.width, width)
|
||||
self.assertGreater(result.compose_ms, 0)
|
||||
|
||||
def test_stage_boundaries_multiseason_and_withered(self):
|
||||
plant = self.plant
|
||||
plot = Plot(1, plant, plant.growth_seconds)
|
||||
view = lambda p, now: self.renderer.scene(
|
||||
self.who, snapshot([p]), now,
|
||||
).plots[0]
|
||||
self.assertEqual(view(plot, 0).state, "growing")
|
||||
self.assertEqual(view(plot, plant.growth_seconds).state, "ripe")
|
||||
self.assertNotEqual(view(plot, 0).sprite, view(plot, 20000).sprite)
|
||||
multi = replace(plant, crop=3)
|
||||
again = Plot(1, multi, multi.regrowth_seconds + 100, 1)
|
||||
self.assertEqual(view(again, 100).stage, 0)
|
||||
self.assertGreater(view(again, again.mature_at - 1).stage, 0)
|
||||
dead = replace(again, harvest_count=3)
|
||||
self.assertEqual(view(dead, 999999).state, "withered")
|
||||
self.assertEqual(view(dead, 999999).sprite, "plant/basic/9.png")
|
||||
|
||||
def test_result_cache_visible_state_scope_and_time(self):
|
||||
plant = self.plant
|
||||
data = snapshot([Plot(1, plant, plant.growth_seconds)])
|
||||
first = self.renderer.render(self.who, data, 0)
|
||||
warm = self.renderer.render(self.who, data, 1)
|
||||
self.assertFalse(first.cache_hit)
|
||||
self.assertTrue(warm.cache_hit)
|
||||
self.assertEqual(first.content, warm.content)
|
||||
ripe = self.renderer.render(self.who, data, plant.growth_seconds)
|
||||
self.assertFalse(ripe.cache_hit)
|
||||
self.assertNotEqual(first.content, ripe.content)
|
||||
changed = replace(data, account=replace(data.account, point=501))
|
||||
self.assertFalse(self.renderer.render(
|
||||
self.who, changed, 0,
|
||||
).cache_hit)
|
||||
other = replace(self.who, realm="other")
|
||||
self.assertFalse(self.renderer.render(other, data, 0).cache_hit)
|
||||
self.assertFalse(self.renderer.render(
|
||||
self.who, data, 0, avatar=b"changed",
|
||||
).cache_hit)
|
||||
|
||||
def test_lru_budget_and_shared_image_safety(self):
|
||||
cache = ByteLRU(10)
|
||||
cache.put("a", b"123456", 6)
|
||||
cache.put("b", b"abcdef", 6)
|
||||
self.assertIsNone(cache.get("a"))
|
||||
self.assertLessEqual(cache.used_bytes, 10)
|
||||
cache.put("huge", bytes(20), 20)
|
||||
self.assertIsNone(cache.get("huge"))
|
||||
image = self.renderer.assets.image("soil/草土地.png", (80, 50))
|
||||
before = image.tobytes()
|
||||
image.paste("red", (0, 0, 80, 50))
|
||||
fresh = self.renderer.assets.image("soil/草土地.png", (80, 50))
|
||||
self.assertEqual(before, fresh.tobytes())
|
||||
self.assertLessEqual(
|
||||
self.renderer.assets.cache.used_bytes,
|
||||
self.renderer.assets.cache.limit,
|
||||
)
|
||||
|
||||
def test_auto_codec_fallback_and_invalid_options(self):
|
||||
with patch("farm_render.models.features.check", return_value=False):
|
||||
self.assertEqual(RenderOptions().codec(("WEBP", "JPEG")), "JPEG")
|
||||
with self.assertRaises(ValueError):
|
||||
RenderOptions(format="WEBP").codec(("WEBP",))
|
||||
for kwargs in ({"width":True}, {"width":99999}, {"quality":101},
|
||||
{"method":7}, {"theme":"../oops"}):
|
||||
with self.assertRaises(ValueError):
|
||||
RenderOptions(**kwargs)
|
||||
|
||||
def test_first_stage_exact_boundary_and_target_canvas(self):
|
||||
plot = Plot(1, self.plant, self.plant.growth_seconds)
|
||||
metadata = self.renderer.assets.metadata["plants"][self.plant.name]
|
||||
threshold = int(metadata["phase"].split(",")[0])
|
||||
data = snapshot([plot])
|
||||
self.assertEqual(self.renderer.scene(
|
||||
self.who, data, threshold - 1,
|
||||
).plots[0].stage, 0)
|
||||
self.assertEqual(self.renderer.scene(
|
||||
self.who, data, threshold,
|
||||
).plots[0].stage, 1)
|
||||
widths = []
|
||||
original = Canvas.__init__
|
||||
|
||||
def capture(canvas, image):
|
||||
widths.append(image.width)
|
||||
original(canvas, image)
|
||||
|
||||
with patch.object(Canvas, "__init__", capture):
|
||||
self.renderer.render(self.who, data, 0)
|
||||
self.assertTrue(widths)
|
||||
self.assertEqual(set(widths), {1024})
|
||||
|
||||
def test_missing_sprite_and_private_logs(self):
|
||||
metadata = self.renderer.assets.metadata
|
||||
plant = next(p for p in Catalogue.bundled() if (
|
||||
f'plant/{p.name}/1.png' not in metadata["images"]
|
||||
))
|
||||
data = snapshot([Plot(1, plant, 0)], name="PRIVATE FARM")
|
||||
with self.assertLogs("farm_render.renderer", "INFO") as recorded:
|
||||
result = self.renderer.render(self.who, data, 0)
|
||||
self.assertTrue(result.content)
|
||||
logs = " ".join(recorded.output)
|
||||
self.assertIn("missing packaged crop sprite", logs)
|
||||
self.assertNotIn("PRIVATE FARM", logs)
|
||||
self.assertNotIn(self.who.key, logs)
|
||||
|
||||
def test_oversized_avatar_is_not_decoded(self):
|
||||
content = BytesIO()
|
||||
Image.new("RGB", (3000, 3000), "white").save(content, "PNG")
|
||||
result = self.renderer.render(
|
||||
self.who, snapshot(), 0, avatar=content.getvalue(),
|
||||
)
|
||||
self.assertTrue(result.content)
|
||||
with self.assertRaises(ValueError):
|
||||
FarmRenderer(Path("missing-font.ttf"))
|
||||
|
||||
|
||||
class AsyncRenderTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.assertIsNotNone(FarmRenderer, "异步渲染模块尚未实现")
|
||||
self.renderer = FarmRenderer(FONT)
|
||||
self.who = Identity("test", "local", "qq", "async")
|
||||
|
||||
async def test_snapshot_once_and_loop_not_blocked(self):
|
||||
class Source:
|
||||
calls = 0
|
||||
|
||||
async def snapshot(inner, who):
|
||||
inner.calls += 1
|
||||
return snapshot()
|
||||
|
||||
source = Source()
|
||||
service = RenderService(self.renderer)
|
||||
original = self.renderer.render
|
||||
reached = threading.Event()
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
reached.set()
|
||||
time.sleep(0.1)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
self.renderer.render = slow
|
||||
task = asyncio.create_task(service.my_farm(source, self.who, now=0))
|
||||
await asyncio.to_thread(reached.wait, 2)
|
||||
self.assertFalse(task.done())
|
||||
self.assertTrue((await task).content)
|
||||
self.assertEqual(source.calls, 1)
|
||||
await service.close()
|
||||
|
||||
async def test_singleflight_cancel_capacity_and_close(self):
|
||||
service = RenderService(self.renderer, concurrency=1, max_pending=1)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
original = self.renderer.render
|
||||
calls = []
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
calls.append(1)
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
self.renderer.render = slow
|
||||
data = snapshot()
|
||||
first = asyncio.create_task(service.render(self.who, data, 0))
|
||||
await asyncio.to_thread(entered.wait, 2)
|
||||
same = asyncio.create_task(service.render(self.who, data, 0))
|
||||
await asyncio.sleep(0)
|
||||
first.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await first
|
||||
with self.assertRaises(RuntimeError):
|
||||
await service.render(replace(self.who, user_id="two"), data, 0)
|
||||
closing = asyncio.create_task(service.close())
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertFalse(closing.done())
|
||||
release.set()
|
||||
self.assertTrue((await same).content)
|
||||
await closing
|
||||
self.assertEqual(len(calls), 1)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await service.render(self.who, data, 0)
|
||||
|
||||
async def test_failure_frees_capacity_for_retry(self):
|
||||
service = RenderService(self.renderer)
|
||||
original = self.renderer.render
|
||||
with patch.object(self.renderer, "render", side_effect=ValueError):
|
||||
with self.assertRaises(ValueError):
|
||||
await service.render(self.who, snapshot(), 0)
|
||||
self.renderer.render = original
|
||||
self.assertTrue((await service.render(
|
||||
self.who, snapshot(), 0,
|
||||
)).content)
|
||||
await service.close()
|
||||
|
||||
async def test_limit_bounds_distinct_parallel_jobs(self):
|
||||
service = RenderService(self.renderer, concurrency=2, max_pending=4)
|
||||
lock = threading.Lock()
|
||||
active = 0
|
||||
maximum = 0
|
||||
original = self.renderer.render
|
||||
|
||||
def observed(*args, **kwargs):
|
||||
nonlocal active, maximum
|
||||
with lock:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
try:
|
||||
time.sleep(0.03)
|
||||
return original(*args, **kwargs)
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
self.renderer.render = observed
|
||||
await asyncio.gather(*(
|
||||
service.render(replace(self.who, user_id=str(i)), snapshot(), 0)
|
||||
for i in range(4)
|
||||
))
|
||||
self.assertEqual(maximum, 2)
|
||||
self.assertEqual(active, 0)
|
||||
await service.close()
|
||||
@@ -0,0 +1,400 @@
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from farm_core import Asset, Catalogue, FarmError, FarmService, Identity
|
||||
from farm_core import SQLiteStore
|
||||
from farm_core.domain import MAX_QUANTITY, Plant, SchemaError, StorageError
|
||||
from farm_core.presentation import receipt_text, snapshot_text
|
||||
from farm_core.repository import SQLiteRepository
|
||||
|
||||
|
||||
def catalogue():
|
||||
return Catalogue([
|
||||
Plant(1, "萝卜", 0, 10, False, 0, 20, 2, 10, 2, 1, 1),
|
||||
Plant(2, "番茄", 0, 5, False, 0, 30, 3, 7, 3, 2, 2),
|
||||
Plant(3, "点券菜", 0, 999, True, 4, 8, 2, 3, 2, 1, 1),
|
||||
Plant(4, "高级菜", 2, 10, False, 0, 20, 2, 10, 2, 1, 1),
|
||||
])
|
||||
|
||||
|
||||
class TransactionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="farm-core-test-")
|
||||
self.path = Path(self.temporary.name) / "farm.sqlite3"
|
||||
self.store = SQLiteStore(self.path)
|
||||
await self.store.initialize()
|
||||
self.now = 1000
|
||||
self.service = FarmService(
|
||||
self.store, catalogue(), lambda: self.now,
|
||||
)
|
||||
self.identity = Identity("deployment", "realm", "qq", "user")
|
||||
await self.service.register(self.identity, "测试农场", "register")
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.store.close()
|
||||
temporary_path = Path(self.temporary.name).resolve()
|
||||
self.assertTrue(temporary_path.is_relative_to(
|
||||
Path(tempfile.gettempdir()).resolve()
|
||||
))
|
||||
self.temporary.cleanup()
|
||||
|
||||
async def seed(self, action, callback):
|
||||
return await self.store.transact(
|
||||
self.identity, action, "test-fixture", {}, self.now, callback,
|
||||
)
|
||||
|
||||
async def mature_radish(self, count=1):
|
||||
await self.service.buy(self.identity, "萝卜", count, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", count, "sow")
|
||||
self.now += 2
|
||||
return await self.service.harvest(self.identity, "harvest")
|
||||
|
||||
async def test_registration_is_immediately_visible_and_not_repeated(self):
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 500)
|
||||
self.assertEqual(snapshot.account.vip_point, 0)
|
||||
self.assertEqual(snapshot.account.experience, 0)
|
||||
self.assertEqual(len(snapshot.plots), 3)
|
||||
self.assertTrue(all(p.plant is None for p in snapshot.plots))
|
||||
repeated = await self.service.register(
|
||||
self.identity, "另一个名称", "register-again",
|
||||
)
|
||||
self.assertFalse(repeated.data["created"])
|
||||
self.assertEqual(repeated.data["account"]["point"], 500)
|
||||
self.assertEqual(len(await self.store.ledger(self.identity)), 1)
|
||||
|
||||
async def test_scopes_are_independent(self):
|
||||
other = replace(self.identity, realm="different")
|
||||
with self.assertRaisesRegex(FarmError, "not_registered"):
|
||||
await self.service.snapshot(other)
|
||||
await self.service.register(other, "另一农场", "register")
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(other)).account.point, 500,
|
||||
)
|
||||
|
||||
async def test_full_purchase_to_sale_round_trip(self):
|
||||
harvest = await self.mature_radish()
|
||||
self.assertEqual(harvest.data["experience"], 20)
|
||||
sale = await self.service.sell(self.identity, None, None, "sell")
|
||||
self.assertEqual(sale.data["proceeds"], 20)
|
||||
self.assertEqual(sale.data["account"]["point"], 510)
|
||||
self.assertEqual(sale.data["account"]["experience"], 20)
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.inventory[0].seeds, 0)
|
||||
self.assertEqual(snapshot.inventory[0].crops, 0)
|
||||
self.assertTrue(snapshot.plots[0].withered)
|
||||
self.assertIn("本次收入:20", receipt_text(sale))
|
||||
self.assertIn("等级:", snapshot_text(snapshot))
|
||||
await self.service.eradicate(self.identity, "eradicate")
|
||||
self.assertTrue(all(
|
||||
p.plant is None
|
||||
for p in (await self.service.snapshot(self.identity)).plots
|
||||
))
|
||||
|
||||
async def test_existing_balance_and_experience_are_not_added_twice(self):
|
||||
await self.seed("existing-exp", lambda repo: (
|
||||
repo.asset(Asset.EXPERIENCE, 100) or {}
|
||||
))
|
||||
await self.mature_radish()
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(self.identity)).account.experience,
|
||||
120,
|
||||
)
|
||||
await self.seed("balance-100", lambda repo: (
|
||||
repo.asset(Asset.POINT, -390) or {}
|
||||
))
|
||||
sale = await self.service.sell(self.identity, "萝卜", None, "sell")
|
||||
self.assertEqual(sale.data["account"]["point"], 120)
|
||||
|
||||
async def test_invalid_inputs_do_not_change_assets(self):
|
||||
before = await self.service.snapshot(self.identity)
|
||||
for count in (0, -1, True, False, 1.5, MAX_QUANTITY + 1, 10**30):
|
||||
with self.subTest(count=count):
|
||||
with self.assertRaises(FarmError):
|
||||
await self.service.buy(
|
||||
self.identity, "萝卜", count, "invalid",
|
||||
)
|
||||
with self.assertRaises(FarmError):
|
||||
await self.service.sow(
|
||||
self.identity, "萝卜", count, "invalid",
|
||||
)
|
||||
with self.assertRaises(FarmError):
|
||||
await self.service.sell(
|
||||
self.identity, "萝卜", count, "invalid",
|
||||
)
|
||||
self.assertEqual(await self.service.snapshot(self.identity), before)
|
||||
|
||||
async def test_unaffordable_unknown_and_high_level_purchase_fail(self):
|
||||
for name, count, code in (
|
||||
("萝卜", 51, "insufficient_funds"),
|
||||
("高级菜", 1, "level_required"),
|
||||
("不存在", 1, "unknown_plant"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
with self.assertRaisesRegex(FarmError, code):
|
||||
await self.service.buy(
|
||||
self.identity, name, count, "denied",
|
||||
)
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 500)
|
||||
self.assertEqual(snapshot.inventory, ())
|
||||
|
||||
async def test_vip_purchase_only_uses_vip_currency(self):
|
||||
with self.assertRaisesRegex(FarmError, "insufficient_funds"):
|
||||
await self.service.buy(self.identity, "点券菜", 1, "vip")
|
||||
await self.seed("vip-fixture", lambda repo: (
|
||||
repo.asset(Asset.VIP_POINT, 10) or {}
|
||||
))
|
||||
result = await self.service.buy(self.identity, "点券菜", 1, "vip")
|
||||
self.assertEqual(result.data["account"]["point"], 500)
|
||||
self.assertEqual(result.data["account"]["vip_point"], 6)
|
||||
|
||||
async def test_sow_all_and_explicit_overflow(self):
|
||||
await self.service.buy(self.identity, "萝卜", 5, "buy")
|
||||
before = await self.service.snapshot(self.identity)
|
||||
with self.assertRaisesRegex(FarmError, "insufficient_empty_plots"):
|
||||
await self.service.sow(self.identity, "萝卜", 4, "too-many")
|
||||
self.assertEqual(await self.service.snapshot(self.identity), before)
|
||||
result = await self.service.sow(self.identity, "萝卜", None, "all")
|
||||
self.assertEqual(result.data["quantity"], 3)
|
||||
self.assertEqual(result.data["seeds"], 2)
|
||||
|
||||
async def test_maturity_boundary_and_multi_crop_lifecycle(self):
|
||||
await self.service.buy(self.identity, "番茄", 1, "buy")
|
||||
await self.service.sow(self.identity, "番茄", 1, "sow")
|
||||
self.now += 2
|
||||
with self.assertRaisesRegex(FarmError, "nothing_to_harvest"):
|
||||
await self.service.harvest(self.identity, "early")
|
||||
with self.assertRaisesRegex(FarmError, "nothing_to_eradicate"):
|
||||
await self.service.eradicate(self.identity, "early-clear")
|
||||
self.now += 1
|
||||
await self.service.harvest(self.identity, "first")
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.plots[0].mature_at, self.now + 2)
|
||||
self.assertFalse(snapshot.plots[0].withered)
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "second")
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertTrue(snapshot.plots[0].withered)
|
||||
self.assertEqual(snapshot.inventory[0].crops, 6)
|
||||
self.assertEqual(snapshot.account.experience, 60)
|
||||
with self.assertRaisesRegex(FarmError, "nothing_to_harvest"):
|
||||
await self.service.harvest(self.identity, "third")
|
||||
|
||||
async def test_planted_rules_are_frozen(self):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
changed = replace(
|
||||
self.service.catalogue.get("萝卜"), harvest=999, experience=999,
|
||||
growth_seconds=100, crop=5,
|
||||
)
|
||||
self.service.catalogue = Catalogue([changed])
|
||||
self.now += 2
|
||||
result = await self.service.harvest(self.identity, "harvest")
|
||||
self.assertEqual(result.data["quantity"], 2)
|
||||
self.assertEqual(result.data["experience"], 20)
|
||||
self.assertTrue(
|
||||
(await self.service.snapshot(self.identity)).plots[0].withered
|
||||
)
|
||||
|
||||
async def test_per_plot_bonuses_are_independent_of_iteration_order(self):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 2, "sow")
|
||||
|
||||
def bonuses(repo):
|
||||
plots = repo.plots()
|
||||
repo.save_plot(replace(plots[0], experience_bonus=100))
|
||||
repo.save_plot(replace(plots[1], experience_bonus=50))
|
||||
return {}
|
||||
|
||||
await self.seed("bonuses", bonuses)
|
||||
self.now += 2
|
||||
original = SQLiteRepository.plots
|
||||
with patch.object(
|
||||
SQLiteRepository, "plots",
|
||||
lambda repo: tuple(reversed(original(repo))),
|
||||
):
|
||||
result = await self.service.harvest(self.identity, "harvest")
|
||||
self.assertEqual(result.data["experience"], 70)
|
||||
|
||||
async def test_locks_protect_both_sale_paths_and_survive_restart(self):
|
||||
await self.mature_radish()
|
||||
await self.service.lock(self.identity, "萝卜", True, "lock")
|
||||
for name, code in (
|
||||
("萝卜", "plant_locked"), (None, "nothing_to_sell"),
|
||||
):
|
||||
with self.assertRaisesRegex(FarmError, code):
|
||||
await self.service.sell(self.identity, name, None, "locked")
|
||||
await self.store.close()
|
||||
await self.store.initialize()
|
||||
self.assertTrue(
|
||||
(await self.service.snapshot(self.identity)).inventory[0].locked
|
||||
)
|
||||
await self.service.lock(self.identity, "萝卜", False, "unlock")
|
||||
result = await self.service.sell(self.identity, None, None, "sale")
|
||||
self.assertEqual(result.data["proceeds"], 20)
|
||||
|
||||
async def test_failed_purchase_rolls_back_money_stock_and_ledger(self):
|
||||
before = await self.service.snapshot(self.identity)
|
||||
ledger = await self.store.ledger(self.identity)
|
||||
with patch.object(
|
||||
SQLiteRepository, "stock",
|
||||
side_effect=sqlite3.OperationalError("injected"),
|
||||
):
|
||||
with self.assertRaises(StorageError):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
self.assertEqual(await self.service.snapshot(self.identity), before)
|
||||
self.assertEqual(await self.store.ledger(self.identity), ledger)
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(self.identity)).account.point, 490,
|
||||
)
|
||||
|
||||
async def test_failed_harvest_rolls_back_crop_and_plot(self):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.now += 2
|
||||
before = await self.service.snapshot(self.identity)
|
||||
original = SQLiteRepository.asset
|
||||
|
||||
def fail_exp(repo, asset, delta):
|
||||
if asset == Asset.EXPERIENCE:
|
||||
raise sqlite3.OperationalError("injected")
|
||||
original(repo, asset, delta)
|
||||
|
||||
with patch.object(SQLiteRepository, "asset", fail_exp):
|
||||
with self.assertRaises(StorageError):
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
self.assertEqual(await self.service.snapshot(self.identity), before)
|
||||
result = await self.service.harvest(self.identity, "harvest")
|
||||
self.assertEqual(result.data["quantity"], 2)
|
||||
|
||||
async def test_failed_sale_rolls_back_inventory(self):
|
||||
await self.mature_radish()
|
||||
before = await self.service.snapshot(self.identity)
|
||||
with patch.object(
|
||||
SQLiteRepository, "asset",
|
||||
side_effect=sqlite3.OperationalError("injected"),
|
||||
):
|
||||
with self.assertRaises(StorageError):
|
||||
await self.service.sell(self.identity, None, None, "sale")
|
||||
self.assertEqual(await self.service.snapshot(self.identity), before)
|
||||
|
||||
async def test_replay_and_parameter_conflict(self):
|
||||
first = await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
self.now += 100
|
||||
second = await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
self.assertEqual(first, second)
|
||||
with self.assertRaisesRegex(FarmError, "operation_conflict"):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
with self.assertRaisesRegex(FarmError, "operation_conflict"):
|
||||
await self.service.sow(self.identity, "萝卜", 1, "buy")
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 490)
|
||||
self.assertEqual(snapshot.inventory[0].seeds, 1)
|
||||
|
||||
async def test_same_operation_concurrently_commits_once(self):
|
||||
results = await asyncio.gather(*(
|
||||
self.service.buy(self.identity, "萝卜", 1, "same")
|
||||
for _ in range(20)
|
||||
))
|
||||
self.assertTrue(all(result == results[0] for result in results))
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 490)
|
||||
self.assertEqual(snapshot.inventory[0].seeds, 1)
|
||||
self.assertEqual(len(await self.store.ledger(self.identity)), 2)
|
||||
|
||||
async def test_concurrent_buyers_cannot_overspend(self):
|
||||
results = await asyncio.gather(*(
|
||||
self.service.buy(self.identity, "萝卜", 1, f"buy-{index}")
|
||||
for index in range(60)
|
||||
), return_exceptions=True)
|
||||
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 50)
|
||||
failures = [r for r in results if isinstance(r, Exception)]
|
||||
self.assertTrue(all(
|
||||
isinstance(r, FarmError) and r.code == "insufficient_funds"
|
||||
for r in failures
|
||||
))
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 0)
|
||||
self.assertEqual(snapshot.inventory[0].seeds, 50)
|
||||
|
||||
async def test_concurrent_harvest_cannot_claim_same_season_twice(self):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.now += 2
|
||||
results = await asyncio.gather(*(
|
||||
self.service.harvest(self.identity, f"harvest-{index}")
|
||||
for index in range(10)
|
||||
), return_exceptions=True)
|
||||
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 1)
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.inventory[0].crops, 2)
|
||||
self.assertEqual(snapshot.account.experience, 20)
|
||||
|
||||
async def test_restart_and_cancelled_request_can_replay_result(self):
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
original = SQLiteRepository.stock
|
||||
|
||||
def blocked(repo, *args, **kwargs):
|
||||
entered.set()
|
||||
if not release.wait(5):
|
||||
raise RuntimeError("Test worker was not released")
|
||||
original(repo, *args, **kwargs)
|
||||
|
||||
with patch.object(SQLiteRepository, "stock", blocked):
|
||||
task = asyncio.create_task(
|
||||
self.service.buy(self.identity, "萝卜", 1, "cancelled-buy")
|
||||
)
|
||||
try:
|
||||
self.assertTrue(await asyncio.to_thread(entered.wait, 3))
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
finally:
|
||||
release.set()
|
||||
await self.store.close()
|
||||
with self.assertRaises(StorageError):
|
||||
await self.service.snapshot(self.identity)
|
||||
await self.store.initialize()
|
||||
result = await self.service.buy(
|
||||
self.identity, "萝卜", 1, "cancelled-buy",
|
||||
)
|
||||
self.assertEqual(result.data["account"]["point"], 490)
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(self.identity)).inventory[0].seeds, 1,
|
||||
)
|
||||
|
||||
async def test_schema_version_is_not_silently_adopted(self):
|
||||
await self.store.close()
|
||||
with closing(sqlite3.connect(self.path)) as connection:
|
||||
connection.execute("PRAGMA user_version=99")
|
||||
with self.assertRaises(SchemaError):
|
||||
await self.store.initialize()
|
||||
|
||||
async def test_foreign_database_is_rejected_without_conversion(self):
|
||||
path = Path(self.temporary.name) / "old.sqlite3"
|
||||
with closing(sqlite3.connect(path)) as connection:
|
||||
connection.execute("CREATE TABLE user(uid TEXT)")
|
||||
other = SQLiteStore(path)
|
||||
with self.assertRaises(SchemaError):
|
||||
await other.initialize()
|
||||
with closing(sqlite3.connect(path)) as connection:
|
||||
self.assertEqual(
|
||||
connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall(), [("user",)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user