2026-10-19 17:25:25 +08:00
|
|
|
import asyncio
|
|
|
|
|
from contextlib import closing
|
|
|
|
|
from dataclasses import replace
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sqlite3
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
|
|
|
|
|
from farm_core.domain import FarmError, Plant
|
|
|
|
|
from farm_core.repository import SQLiteRepository
|
2026-10-22 18:33:42 +08:00
|
|
|
from farm_core.pests import advance_plot, start_growth
|
2026-10-19 17:25:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PestTests(unittest.IsolatedAsyncioTestCase):
|
|
|
|
|
async def asyncSetUp(self):
|
|
|
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.path = Path(self.temp.name) / "farm.db"
|
|
|
|
|
self.store = SQLiteStore(self.path)
|
|
|
|
|
await self.store.initialize()
|
|
|
|
|
self.now = 1000
|
|
|
|
|
self.plant = Plant(1, "虫害测试菜", 0, 1, False, 0, 20, 100,
|
|
|
|
|
1, 14400, 2, 14400)
|
|
|
|
|
self.service = FarmService(self.store, Catalogue([self.plant]),
|
|
|
|
|
lambda: self.now)
|
|
|
|
|
self.who = Identity("test", "local", "qq", "pests")
|
|
|
|
|
await self.service.register(self.who, "测试农场", "register")
|
|
|
|
|
await self.service.buy(self.who, "虫害测试菜", 1, "buy")
|
|
|
|
|
await self.service.sow(self.who, "虫害测试菜", 1, "sow")
|
|
|
|
|
|
|
|
|
|
async def asyncTearDown(self):
|
|
|
|
|
await self.store.close()
|
|
|
|
|
self.temp.cleanup()
|
|
|
|
|
|
|
|
|
|
async def plot(self):
|
|
|
|
|
return (await self.service.snapshot(self.who)).plots[0]
|
|
|
|
|
|
|
|
|
|
async def test_speed_integrates_each_pest_interval_and_offline_maturity(self):
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 8199
|
|
|
|
|
self.assertEqual((await self.plot()).pest_count, 0)
|
|
|
|
|
self.now = 8200
|
2026-10-19 17:25:25 +08:00
|
|
|
first = await self.plot()
|
|
|
|
|
self.assertEqual(first.pest_count, 1)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(first.growth_remaining, 720000)
|
|
|
|
|
self.now = 15400
|
2026-10-19 17:25:25 +08:00
|
|
|
second = await self.plot()
|
|
|
|
|
self.assertEqual(second.pest_count, 2)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(second.growth_remaining, 36000)
|
|
|
|
|
self.now = 15799
|
2026-10-19 17:25:25 +08:00
|
|
|
self.assertGreater((await self.plot()).growth_remaining, 0)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 15800
|
2026-10-19 17:25:25 +08:00
|
|
|
mature = await self.plot()
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(mature.mature_at, 15800)
|
|
|
|
|
self.assertEqual(mature.maturity_pests, 2)
|
2026-10-19 17:25:25 +08:00
|
|
|
receipt = await self.service.harvest(self.who, "harvest")
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(receipt.data["quantity"], 90)
|
2026-10-19 17:25:25 +08:00
|
|
|
self.assertEqual(receipt.data["experience"], 20)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(receipt.data["harvested"][0]["pests"], 2)
|
2026-10-19 17:25:25 +08:00
|
|
|
again = await self.plot()
|
|
|
|
|
self.assertEqual(again.pest_count, 0)
|
|
|
|
|
self.assertEqual(again.maturity_pests, 0)
|
|
|
|
|
|
|
|
|
|
async def test_clear_before_maturity_restores_rate_but_not_lost_time(self):
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 10000
|
2026-10-19 17:25:25 +08:00
|
|
|
receipt = await self.service.clear_pests(self.who, "clear")
|
|
|
|
|
self.assertEqual(receipt.data["quantity"], 1)
|
|
|
|
|
cleared = await self.plot()
|
|
|
|
|
self.assertEqual(cleared.pest_count, 0)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(cleared.growth_remaining, 549000)
|
2026-10-19 17:25:25 +08:00
|
|
|
self.assertEqual(cleared.pest_spawned, 1)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 15400
|
2026-10-19 17:25:25 +08:00
|
|
|
self.assertEqual((await self.plot()).pest_count, 1)
|
|
|
|
|
repeated = await self.service.clear_pests(self.who, "clear")
|
|
|
|
|
self.assertEqual(repeated, receipt)
|
|
|
|
|
self.assertEqual((await self.plot()).pest_count, 1)
|
|
|
|
|
|
|
|
|
|
async def test_clear_after_maturity_cannot_erase_season_penalty(self):
|
|
|
|
|
self.now = 20000
|
|
|
|
|
await self.service.clear_pests(self.who, "late-clear")
|
|
|
|
|
mature = await self.plot()
|
|
|
|
|
self.assertEqual(mature.pest_count, 0)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(mature.maturity_pests, 2)
|
2026-10-19 17:25:25 +08:00
|
|
|
receipt = await self.service.harvest(self.who, "harvest")
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(receipt.data["quantity"], 90)
|
2026-10-19 17:25:25 +08:00
|
|
|
|
|
|
|
|
async def test_reopen_preserves_cleared_progress_and_concurrent_clear(self):
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 10000
|
2026-10-19 17:25:25 +08:00
|
|
|
results = await asyncio.gather(
|
|
|
|
|
self.service.clear_pests(self.who, "clear-1"),
|
|
|
|
|
self.service.clear_pests(self.who, "clear-2"),
|
|
|
|
|
return_exceptions=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(sum(not isinstance(r, Exception) for r in results), 1)
|
|
|
|
|
self.assertTrue(any(isinstance(r, FarmError) for r in results))
|
|
|
|
|
before = await self.plot()
|
|
|
|
|
await self.store.close()
|
|
|
|
|
await self.store.initialize()
|
|
|
|
|
self.assertEqual(await self.plot(), before)
|
|
|
|
|
with closing(sqlite3.connect(self.path)) as db:
|
|
|
|
|
self.assertEqual(db.execute(
|
|
|
|
|
"SELECT pest_count, pest_spawned FROM plots WHERE slot=1"
|
|
|
|
|
).fetchone(), (0, 1))
|
|
|
|
|
|
|
|
|
|
async def test_failed_clear_rolls_back_all_plot_updates(self):
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 10000
|
2026-10-19 17:25:25 +08:00
|
|
|
before = await self.store.snapshot(self.who)
|
|
|
|
|
original = SQLiteRepository.save_plot
|
|
|
|
|
|
|
|
|
|
def fail(repository, plot):
|
|
|
|
|
original(repository, plot)
|
|
|
|
|
raise RuntimeError("fixture failure")
|
|
|
|
|
|
|
|
|
|
with patch.object(SQLiteRepository, "save_plot", fail):
|
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
|
|
|
await self.service.clear_pests(self.who, "failed-clear")
|
|
|
|
|
self.assertEqual(await self.store.snapshot(self.who), before)
|
|
|
|
|
|
2026-10-20 14:13:31 +08:00
|
|
|
async def test_empty_clear_has_no_reward_and_clear_awards_per_pest(self):
|
2026-10-19 17:25:25 +08:00
|
|
|
with self.assertRaisesRegex(FarmError, "nothing_to_clear"):
|
|
|
|
|
await self.service.clear_pests(self.who, "empty-clear")
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 8200
|
2026-10-19 17:25:25 +08:00
|
|
|
before = await self.service.snapshot(self.who)
|
|
|
|
|
await self.service.clear_pests(self.who, "clear")
|
|
|
|
|
after = await self.service.snapshot(self.who)
|
2026-10-20 14:13:31 +08:00
|
|
|
self.assertEqual(after.account.experience, before.account.experience+1)
|
|
|
|
|
self.assertEqual(after.account.point, before.account.point)
|
|
|
|
|
self.assertEqual(after.account.vip_point, before.account.vip_point)
|
|
|
|
|
|
|
|
|
|
async def test_multi_plot_pest_rewards_replay_and_transaction_rollback(self):
|
|
|
|
|
await self.service.buy(self.who, '虫害测试菜', 1, 'buy-second')
|
|
|
|
|
await self.service.sow(self.who, '虫害测试菜', 1, 'sow-second')
|
2026-10-22 18:33:42 +08:00
|
|
|
self.now = 15400
|
2026-10-20 14:13:31 +08:00
|
|
|
before = await self.store.snapshot(self.who)
|
|
|
|
|
ledger = await self.store.ledger(self.who)
|
|
|
|
|
with patch.object(SQLiteRepository, 'asset',
|
|
|
|
|
side_effect=RuntimeError('reward failure')):
|
|
|
|
|
with self.assertRaises(RuntimeError):
|
2026-10-22 18:33:42 +08:00
|
|
|
await self.service.clear_pests(self.who, 'clear-four')
|
2026-10-20 14:13:31 +08:00
|
|
|
self.assertEqual(await self.store.snapshot(self.who), before)
|
|
|
|
|
self.assertEqual(await self.store.ledger(self.who), ledger)
|
|
|
|
|
receipts = await asyncio.gather(*(
|
2026-10-22 18:33:42 +08:00
|
|
|
self.service.clear_pests(self.who, 'clear-four') for _ in range(3)
|
2026-10-20 14:13:31 +08:00
|
|
|
))
|
|
|
|
|
self.assertTrue(all(r == receipts[0] for r in receipts))
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(receipts[0].data['quantity'], 4)
|
|
|
|
|
self.assertEqual(receipts[0].data['experience'], 4)
|
2026-10-20 14:13:31 +08:00
|
|
|
after = await self.service.snapshot(self.who)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(after.account.experience, before.account.experience+4)
|
2026-10-20 14:13:31 +08:00
|
|
|
with self.assertRaisesRegex(FarmError, 'nothing_to_clear'):
|
|
|
|
|
await self.service.clear_pests(self.who, 'empty-after')
|
|
|
|
|
self.assertEqual((await self.service.snapshot(self.who)).account,
|
|
|
|
|
after.account)
|
2026-10-19 17:25:25 +08:00
|
|
|
|
|
|
|
|
async def test_projection_is_independent_of_query_frequency(self):
|
|
|
|
|
initial = (await self.store.snapshot(self.who)).plots[0]
|
|
|
|
|
direct = advance_plot(initial, 20000)
|
|
|
|
|
split = initial
|
2026-10-22 18:33:42 +08:00
|
|
|
for instant in (1001, 8199, 8200, 10000, 15400, 15800, 20000):
|
2026-10-19 17:25:25 +08:00
|
|
|
split = advance_plot(split, instant)
|
|
|
|
|
self.assertEqual(split, direct)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(direct.maturity_pests, 2)
|
2026-10-19 17:25:25 +08:00
|
|
|
|
|
|
|
|
async def test_growth_stage_uses_work_not_predicted_finish_time(self):
|
|
|
|
|
import sys
|
|
|
|
|
root = Path(__file__).resolve().parents[1]
|
|
|
|
|
sys.path.insert(0, str(root / "scripts"))
|
|
|
|
|
from ci_font import choose_font
|
|
|
|
|
from farm_render import FarmRenderer
|
|
|
|
|
|
|
|
|
|
renderer = FarmRenderer(choose_font())
|
|
|
|
|
self.plant = replace(self.plant, name="草莓")
|
|
|
|
|
self.service.catalogue = Catalogue([self.plant])
|
|
|
|
|
stored = (await self.store.snapshot(self.who)).plots[0]
|
|
|
|
|
self.now = 8200
|
|
|
|
|
projected = advance_plot(replace(stored, plant=self.plant), self.now)
|
|
|
|
|
snapshot = replace(await self.service.snapshot(self.who),
|
|
|
|
|
plots=(projected,))
|
|
|
|
|
view = renderer.scene(self.who, snapshot, self.now).plots[0]
|
|
|
|
|
self.assertEqual(view.stage, 2)
|
2026-10-22 18:33:42 +08:00
|
|
|
self.assertEqual(view.pest_count, 1)
|
|
|
|
|
|
|
|
|
|
async def test_short_crops_mature_without_pests(self):
|
|
|
|
|
stored = (await self.store.snapshot(self.who)).plots[0]
|
|
|
|
|
for duration in (3600, 7200):
|
|
|
|
|
with self.subTest(duration=duration):
|
|
|
|
|
plant = replace(self.plant, growth_seconds=duration)
|
|
|
|
|
plot = start_growth(replace(stored, plant=plant), 1000)
|
|
|
|
|
mature = advance_plot(plot, 1000 + duration)
|
|
|
|
|
self.assertEqual(mature.growth_remaining, 0)
|
|
|
|
|
self.assertEqual(mature.mature_at, 1000 + duration)
|
|
|
|
|
self.assertEqual(mature.pest_count, 0)
|
|
|
|
|
self.assertEqual(mature.maturity_pests, 0)
|
|
|
|
|
|
|
|
|
|
async def test_regrowth_uses_its_own_longer_pest_interval(self):
|
|
|
|
|
stored = (await self.store.snapshot(self.who)).plots[0]
|
|
|
|
|
plant = replace(self.plant, regrowth_seconds=36001)
|
|
|
|
|
plot = start_growth(replace(
|
|
|
|
|
stored, plant=plant, harvest_count=1,
|
|
|
|
|
), 1000)
|
|
|
|
|
self.assertEqual(advance_plot(plot, 19000).pest_count, 0)
|
|
|
|
|
self.assertEqual(advance_plot(plot, 19001).pest_count, 1)
|
|
|
|
|
|
|
|
|
|
async def test_pending_pest_is_preserved_then_spacing_increases(self):
|
|
|
|
|
stored = (await self.store.snapshot(self.who)).plots[0]
|
|
|
|
|
old = replace(stored, next_pest_at=4600)
|
|
|
|
|
first = advance_plot(old, 4600)
|
|
|
|
|
self.assertEqual(first.pest_count, 1)
|
|
|
|
|
self.assertEqual(first.next_pest_at, 11800)
|
|
|
|
|
self.assertEqual(advance_plot(first, 11799).pest_count, 1)
|
|
|
|
|
self.assertEqual(advance_plot(first, 11800).pest_count, 2)
|