add # 新增项目整体大纲

This commit is contained in:
2026-10-15 17:21:05 +08:00
parent ff03600ae9
commit ff4df9ad51
52 changed files with 15104 additions and 47 deletions
+9 -10
View File
@@ -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运行矩阵尚待后续实现。
+72
View File
@@ -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()
+147
View File
@@ -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()
+296
View File
@@ -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()
+400
View File
@@ -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()