mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-29 13:40:00 +08:00
fixed # 修复作物资源位置不对的历史遗留BUG
add # 新增作物会生虫的逻辑
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import json
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, replace
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from farm_core import Catalogue, FarmError, FarmService, Identity, SQLiteStore
|
||||
from farm_core.domain import Plant, SchemaError
|
||||
from farm_core.storage import APPLICATION_ID, SCHEMA, SCHEMA_VERSION
|
||||
|
||||
|
||||
def plant(plant_id=1, name="萝卜", **changes):
|
||||
return replace(
|
||||
Plant(plant_id, name, 0, 10, False, 0, 2, 3, 4, 2, 1, 1),
|
||||
**changes,
|
||||
)
|
||||
|
||||
|
||||
class CropIdentityTests(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.identity = Identity("test", "realm", "qq", "user")
|
||||
self.service = FarmService(
|
||||
self.store, Catalogue([plant()]), lambda: self.now,
|
||||
)
|
||||
await self.service.register(self.identity, "农场", "register")
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.store.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
async def test_same_name_requires_id_and_keeps_inventory_separate(self):
|
||||
self.service.catalogue = Catalogue([plant(), plant(2)])
|
||||
with self.assertRaisesRegex(FarmError, "ambiguous_plant"):
|
||||
await self.service.buy(self.identity, "萝卜", 1, "ambiguous")
|
||||
await self.service.buy(self.identity, "#1", 1, "first")
|
||||
await self.service.buy(self.identity, "#2", 2, "second")
|
||||
items = (await self.service.snapshot(self.identity)).inventory
|
||||
self.assertEqual(
|
||||
[(i.plant_id, i.seeds) for i in items], [(1, 1), (2, 2)],
|
||||
)
|
||||
await self.service.sow(self.identity, "#1", 1, "sow")
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.lock(self.identity, "#1", True, "lock")
|
||||
with self.assertRaisesRegex(FarmError, "plant_locked"):
|
||||
await self.service.sell(self.identity, "#1", None, "locked")
|
||||
await self.service.lock(self.identity, "#1", False, "unlock")
|
||||
sale = await self.service.sell(self.identity, None, None, "sale")
|
||||
self.assertEqual(sale.data["proceeds"], 12)
|
||||
self.assertEqual(
|
||||
(await self.service.snapshot(self.identity)).inventory[1].seeds, 2,
|
||||
)
|
||||
|
||||
async def test_rename_preserves_seeds_crops_and_lock(self):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.service.catalogue = Catalogue([plant(name="新萝卜")])
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.lock(self.identity, "新萝卜", True, "lock")
|
||||
item = (await self.service.snapshot(self.identity)).inventory[0]
|
||||
self.assertEqual((item.plant_id, item.seeds, item.crops), (1, 1, 3))
|
||||
self.assertTrue(item.locked)
|
||||
await self.service.lock(self.identity, "新萝卜", False, "unlock")
|
||||
sale = await self.service.sell(self.identity, "新萝卜", None, "sale")
|
||||
self.assertEqual(sale.data["proceeds"], 12)
|
||||
|
||||
async def test_purchase_rules_reject_without_debit(self):
|
||||
for index, changes in enumerate((
|
||||
{"purchasable": False}, {"enabled": False}, {"crop_type": 11},
|
||||
)):
|
||||
self.service.catalogue = Catalogue([plant(**changes)])
|
||||
with self.assertRaises(FarmError):
|
||||
await self.service.buy(self.identity, "萝卜", 1, str(index))
|
||||
snapshot = await self.service.snapshot(self.identity)
|
||||
self.assertEqual(snapshot.account.point, 500)
|
||||
self.assertEqual(snapshot.inventory, ())
|
||||
|
||||
async def test_delisting_allows_sowing_but_disabled_blocks_it(self):
|
||||
await self.service.buy(self.identity, "萝卜", 2, "buy")
|
||||
self.service.catalogue = Catalogue([plant(purchasable=False)])
|
||||
await self.service.sow(self.identity, "萝卜", 1, "sow")
|
||||
self.service.catalogue = Catalogue([plant(enabled=False)])
|
||||
with self.assertRaisesRegex(FarmError, "plant_disabled"):
|
||||
await self.service.sow(self.identity, "萝卜", 1, "disabled")
|
||||
self.now += 2
|
||||
await self.service.harvest(self.identity, "harvest")
|
||||
await self.service.sell(self.identity, None, None, "sell")
|
||||
|
||||
|
||||
class CatalogueMetadataTests(unittest.TestCase):
|
||||
def test_local_identity_has_no_web_id(self):
|
||||
carrot = Catalogue.bundled().get("胡萝卜")
|
||||
self.assertEqual(carrot.plant_id, 1)
|
||||
self.assertNotIn("source_crop_id", asdict(carrot))
|
||||
|
||||
def test_legacy_purchase_flag_is_preserved(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
data = json.loads(
|
||||
(root / "src/farm_core/data/plants.json").read_bytes()
|
||||
)
|
||||
for row in data["plants"]:
|
||||
self.assertEqual(
|
||||
Plant.from_legacy(row).purchasable, bool(row["isBuy"]),
|
||||
)
|
||||
|
||||
|
||||
class MigrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def create_v1(self, path, unknown=False, version=1):
|
||||
with closing(sqlite3.connect(path)) as db, db:
|
||||
for statement in SCHEMA:
|
||||
if version == 1 and statement.startswith("CREATE TABLE inventory("):
|
||||
statement = """CREATE TABLE inventory(
|
||||
identity TEXT, name TEXT, seeds INTEGER,
|
||||
crops INTEGER, locked INTEGER,
|
||||
PRIMARY KEY(identity, name))"""
|
||||
if statement.startswith("CREATE TABLE plots("):
|
||||
statement = """CREATE TABLE plots(
|
||||
identity TEXT, slot INTEGER, plant TEXT,
|
||||
mature_at INTEGER, harvest_count INTEGER,
|
||||
yield_bonus INTEGER, experience_bonus INTEGER,
|
||||
PRIMARY KEY(identity, slot))"""
|
||||
db.execute(statement)
|
||||
db.execute(f"PRAGMA application_id={APPLICATION_ID}")
|
||||
db.execute(f"PRAGMA user_version={version}")
|
||||
db.execute("INSERT INTO accounts VALUES ('user', '农场', 123, 4, 5)")
|
||||
name = "不认识的作物" if unknown else "萝卜"
|
||||
if version == 1:
|
||||
db.execute(
|
||||
"INSERT INTO inventory VALUES ('user', ?, 7, 9, 1)",
|
||||
(name,),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"INSERT INTO inventory VALUES ('user', 1, ?, 7, 9, 1)",
|
||||
(name,),
|
||||
)
|
||||
snapshot = asdict(plant())
|
||||
for key in ("purchasable", "enabled", "crop_type"):
|
||||
snapshot.pop(key)
|
||||
db.execute(
|
||||
"INSERT INTO plots VALUES ('user', 1, ?, 1234, 0, 0, 0)",
|
||||
(json.dumps(snapshot),),
|
||||
)
|
||||
|
||||
async def test_v1_migration_preserves_inventory_and_is_repeatable(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path)
|
||||
store = SQLiteStore(path)
|
||||
await store.initialize(catalogue=Catalogue([plant()]))
|
||||
await store.close()
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0],
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT plant_id, name, seeds, crops, locked FROM inventory"
|
||||
).fetchone()
|
||||
self.assertEqual(row, (1, "萝卜", 7, 9, 1))
|
||||
self.assertEqual(
|
||||
db.execute("SELECT point FROM accounts").fetchone()[0], 123,
|
||||
)
|
||||
self.assertEqual(
|
||||
db.execute("SELECT mature_at FROM plots").fetchone()[0],
|
||||
1234,
|
||||
)
|
||||
snapshot = json.loads(db.execute(
|
||||
"SELECT plant FROM plots"
|
||||
).fetchone()[0])
|
||||
self.assertEqual(Plant(**snapshot).plant_id, 1)
|
||||
|
||||
async def test_v2_migration_keeps_existing_season_without_retroactive_pests(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path, version=2)
|
||||
store = SQLiteStore(path)
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
await store.initialize()
|
||||
await store.close()
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(db.execute(
|
||||
"PRAGMA user_version"
|
||||
).fetchone()[0], SCHEMA_VERSION)
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT mature_at, pest_count, pest_spawned, "
|
||||
"growth_remaining, maturity_pests FROM plots"
|
||||
).fetchone(), (1234, 0, 0, None, 0))
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT point FROM accounts"
|
||||
).fetchone()[0], 123)
|
||||
|
||||
async def test_unknown_v1_crop_rolls_back_instead_of_losing_stock(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path, unknown=True)
|
||||
store = SQLiteStore(path)
|
||||
with self.assertRaises(SchemaError):
|
||||
await store.initialize(catalogue=Catalogue([plant()]))
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0], 1,
|
||||
)
|
||||
self.assertEqual(
|
||||
db.execute("SELECT seeds, crops FROM inventory").fetchone(),
|
||||
(7, 9),
|
||||
)
|
||||
|
||||
async def test_ambiguous_v1_names_abort_without_partial_migration(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "farm.db"
|
||||
self.create_v1(path)
|
||||
store = SQLiteStore(path)
|
||||
with self.assertRaisesRegex(
|
||||
SchemaError, "inventory_identity_unresolved",
|
||||
):
|
||||
await store.initialize(
|
||||
catalogue=Catalogue([plant(), plant(2)]),
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
self.assertEqual(
|
||||
db.execute("PRAGMA user_version").fetchone()[0], 1,
|
||||
)
|
||||
self.assertEqual(db.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE name='inventory_v2'"
|
||||
).fetchall(), [])
|
||||
@@ -0,0 +1,160 @@
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from farm_core import Catalogue, Identity
|
||||
from farm_core.domain import Account, Plot, Snapshot
|
||||
from farm_render import FarmRenderer, RenderOptions
|
||||
from farm_render.canvas import Canvas
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from ci_font import choose_font
|
||||
|
||||
|
||||
class CropPositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.renderer = FarmRenderer(choose_font())
|
||||
self.who = Identity("test", "local", "qq", "one")
|
||||
|
||||
def scene(self, name, now):
|
||||
plant = Catalogue.bundled().get(name)
|
||||
return self.renderer.scene(self.who, Snapshot(
|
||||
Account("公式预览", 500, 0, 0), (),
|
||||
(Plot(1, plant, plant.growth_seconds),),
|
||||
), now)
|
||||
|
||||
def test_growth_stages_use_full_canvas_bottom_for_every_crop(self):
|
||||
for name in ("草莓", "橙子", "樱桃", "茄子", "黄瓜"):
|
||||
phases = self.renderer.assets.metadata["plants"][name]["phase"]
|
||||
for now in dict.fromkeys(map(int, phases.split(","))):
|
||||
for width in (1024, 1536, 3072):
|
||||
with self.subTest(name=name, now=now, width=width):
|
||||
scene = self.scene(name, now)
|
||||
canvas = Canvas.new((width, round(1872*width/3072)))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._plants(
|
||||
canvas, scene, RenderOptions(width=width),
|
||||
)
|
||||
image, xy = paste.call_args_list[0].args
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
sw, sh = meta["soil_size"]
|
||||
scale = width / 3072
|
||||
self.assertLessEqual(abs(
|
||||
xy[0] + image.width / 2
|
||||
- (pos["x"] + sw/2) * scale
|
||||
), 1)
|
||||
self.assertLessEqual(abs(
|
||||
xy[1] + image.height
|
||||
- (pos["y"] + sh) * scale
|
||||
), .5)
|
||||
native = meta["images"][scene.plots[0].sprite]["size"]
|
||||
self.assertEqual(image.size, tuple(
|
||||
max(1, round(n * .75 * sh / 79.5 * scale))
|
||||
for n in native
|
||||
))
|
||||
|
||||
def test_seed_and_wither_use_public_sprites_and_distinct_targets(self):
|
||||
seed = self.scene("草莓", 0)
|
||||
self.assertEqual(seed.plots[0].sprite, "webgl/seed.png")
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
dead = self.renderer.scene(self.who, Snapshot(
|
||||
seed.account, (), (Plot(1, plant, None, plant.crop),),
|
||||
), 999999)
|
||||
self.assertEqual(dead.plots[0].sprite, "webgl/wither.png")
|
||||
for scene, shift in ((seed, 0), (dead, 8)):
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._plants(canvas, scene, RenderOptions(width=3072))
|
||||
image, xy = paste.call_args_list[0].args
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
height = meta["soil_size"][1]
|
||||
self.assertLessEqual(abs(
|
||||
xy[1] + image.height - (pos["y"] + height/2
|
||||
+ shift*height/79.5)
|
||||
), .5)
|
||||
|
||||
def test_missing_sprite_keeps_safe_placeholder(self):
|
||||
scene = self.scene("草莓", 14400)
|
||||
self.renderer.assets.metadata["images"].pop(scene.plots[0].sprite)
|
||||
result = self.renderer.render(
|
||||
self.who, Snapshot(scene.account, (), (
|
||||
Plot(1, Catalogue.bundled().get("草莓"), 86400),
|
||||
)), 14400,
|
||||
)
|
||||
self.assertTrue(result.content)
|
||||
|
||||
def test_exported_configuration_has_no_manual_stage_anchors(self):
|
||||
meta = self.renderer.assets.metadata
|
||||
self.assertEqual(meta["placement"]["schema"], 2)
|
||||
self.assertTrue(all("stages" not in p for p in meta["plants"].values()))
|
||||
|
||||
def test_pest_count_changes_cache_and_pest_positions_use_land_center(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
clean = Snapshot(Account("虫害预览", 0, 0, 0), (), (
|
||||
Plot(1, plant, 86400),
|
||||
))
|
||||
infested = replace(clean, plots=(replace(
|
||||
clean.plots[0], pest_count=3, pest_spawned=3,
|
||||
),))
|
||||
first = self.renderer.render(self.who, clean, 40000)
|
||||
second = self.renderer.render(self.who, infested, 40000)
|
||||
self.assertFalse(second.cache_hit)
|
||||
self.assertNotEqual(first.content, second.content)
|
||||
scene = self.renderer.scene(self.who, infested, 40000)
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "paste") as paste:
|
||||
self.renderer._pests(canvas, scene, RenderOptions(width=3072))
|
||||
self.assertEqual(paste.call_count, 3)
|
||||
meta = self.renderer.assets.metadata
|
||||
pos = meta["positions"][0]
|
||||
sw, sh = meta["soil_size"]
|
||||
unit = sh / 79.5
|
||||
for call, (x, y) in zip(paste.call_args_list,
|
||||
((-26, 13), (-5, 42), (24, 24))):
|
||||
image, xy = call.args
|
||||
self.assertLessEqual(abs(xy[0] + image.width/2
|
||||
- (pos["x"]+sw/2+x*unit)), .5)
|
||||
self.assertLessEqual(abs(xy[1] + image.height/2
|
||||
- (pos["y"]+sh/2-y*unit)), .5)
|
||||
self.assertEqual(len(meta["pest"]["frames"]), 38)
|
||||
|
||||
def test_pest_frames_are_varied_and_stable_across_renderers_and_sizes(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
data = Snapshot(Account("随机帧测试", 0, 0, 0), (), tuple(
|
||||
Plot(slot, plant, 86400, pest_count=3, pest_spawned=3)
|
||||
for slot in range(1, 6)
|
||||
))
|
||||
scene = self.renderer.scene(self.who, data, 40000)
|
||||
restarted = FarmRenderer(choose_font())
|
||||
self.assertEqual(scene, restarted.scene(self.who, data, 40001))
|
||||
frames = [frame for view in scene.plots for frame in view.pest_frames]
|
||||
self.assertGreater(len(set(frames)), 3)
|
||||
for view in scene.plots:
|
||||
self.assertEqual(len(set(view.pest_frames)), 3)
|
||||
for width in (1024, 1536):
|
||||
options = RenderOptions(width=width, format="PNG")
|
||||
first = self.renderer.render(self.who, data, 40000, options)
|
||||
cached = self.renderer.render(self.who, data, 40001, options)
|
||||
cold = restarted.render(self.who, data, 40001, options)
|
||||
self.assertTrue(cached.cache_hit)
|
||||
self.assertEqual(first.content, cached.content)
|
||||
self.assertEqual(first.content, cold.content)
|
||||
|
||||
def test_pest_frames_survive_partial_clear_and_change_for_next_season(self):
|
||||
plant = Catalogue.bundled().get("草莓")
|
||||
plot = Plot(1, plant, 86400, pest_count=3, pest_spawned=3)
|
||||
data = Snapshot(Account("随机帧测试", 0, 0, 0), (), (plot,))
|
||||
initial = self.renderer.scene(self.who, data, 40000).plots[0]
|
||||
fewer = replace(data, plots=(replace(plot, pest_count=1),))
|
||||
remaining = self.renderer.scene(self.who, fewer, 40000).plots[0]
|
||||
self.assertEqual(remaining.pest_frames, initial.pest_frames[:1])
|
||||
next_season = replace(data, plots=(replace(
|
||||
plot, harvest_count=1, mature_at=plant.regrowth_seconds+100,
|
||||
),))
|
||||
changed = self.renderer.scene(self.who, next_season, 40000).plots[0]
|
||||
self.assertNotEqual(changed.pest_frames, initial.pest_frames)
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
from farm_core.pests import advance_plot
|
||||
|
||||
|
||||
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):
|
||||
self.now = 4600
|
||||
first = await self.plot()
|
||||
self.assertEqual(first.pest_count, 1)
|
||||
self.assertEqual(first.growth_remaining, 1080000)
|
||||
self.now = 8200
|
||||
second = await self.plot()
|
||||
self.assertEqual(second.pest_count, 2)
|
||||
self.assertEqual(second.growth_remaining, 738000)
|
||||
self.now = 11800
|
||||
third = await self.plot()
|
||||
self.assertEqual(third.pest_count, 3)
|
||||
self.assertEqual(third.growth_remaining, 414000)
|
||||
self.now = 16670
|
||||
self.assertGreater((await self.plot()).growth_remaining, 0)
|
||||
self.now = 16671
|
||||
mature = await self.plot()
|
||||
self.assertEqual(mature.mature_at, 16671)
|
||||
self.assertEqual(mature.maturity_pests, 3)
|
||||
receipt = await self.service.harvest(self.who, "harvest")
|
||||
self.assertEqual(receipt.data["quantity"], 85)
|
||||
self.assertEqual(receipt.data["experience"], 20)
|
||||
self.assertEqual(receipt.data["harvested"][0]["pests"], 3)
|
||||
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):
|
||||
self.now = 6400
|
||||
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)
|
||||
self.assertEqual(cleared.growth_remaining, 909000)
|
||||
self.assertEqual(cleared.pest_spawned, 1)
|
||||
self.now = 8200
|
||||
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)
|
||||
self.assertEqual(mature.maturity_pests, 3)
|
||||
receipt = await self.service.harvest(self.who, "harvest")
|
||||
self.assertEqual(receipt.data["quantity"], 85)
|
||||
|
||||
async def test_reopen_preserves_cleared_progress_and_concurrent_clear(self):
|
||||
self.now = 6400
|
||||
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):
|
||||
self.now = 6400
|
||||
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)
|
||||
|
||||
async def test_empty_clear_and_ripe_clear_reward_no_assets(self):
|
||||
with self.assertRaisesRegex(FarmError, "nothing_to_clear"):
|
||||
await self.service.clear_pests(self.who, "empty-clear")
|
||||
self.now = 4600
|
||||
before = await self.service.snapshot(self.who)
|
||||
await self.service.clear_pests(self.who, "clear")
|
||||
after = await self.service.snapshot(self.who)
|
||||
self.assertEqual(before.account, after.account)
|
||||
|
||||
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
|
||||
for instant in (1001, 4599, 4600, 7000, 11800, 16671, 20000):
|
||||
split = advance_plot(split, instant)
|
||||
self.assertEqual(split, direct)
|
||||
self.assertEqual(direct.maturity_pests, 3)
|
||||
|
||||
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)
|
||||
self.assertEqual(view.pest_count, 2)
|
||||
+58
-1
@@ -90,7 +90,7 @@ class RenderTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(view(dead, 999999).sprite, "webgl/wither.png")
|
||||
|
||||
def test_result_cache_visible_state_scope_and_time(self):
|
||||
plant = self.plant
|
||||
@@ -112,6 +112,63 @@ class RenderTests(unittest.TestCase):
|
||||
self.assertFalse(self.renderer.render(
|
||||
self.who, data, 0, avatar=b"changed",
|
||||
).cache_hit)
|
||||
changed = replace(data, account=replace(data.account, experience=200))
|
||||
self.assertFalse(self.renderer.render(self.who, changed, 0).cache_hit)
|
||||
|
||||
def test_legacy_panel_assets_avatar_size_and_layer_order(self):
|
||||
avatar = BytesIO()
|
||||
Image.new("RGB", (80, 40), "red").save(avatar, "PNG")
|
||||
scene = self.renderer.scene(self.who, snapshot(), 0)
|
||||
for width in (1024, 1536, 3072):
|
||||
options = RenderOptions(width=width)
|
||||
s = lambda n: round(n * width / 3072)
|
||||
actual = Canvas.new((width, s(1872)))
|
||||
expected = Canvas.new((width, s(1872)))
|
||||
name = "background/userInfo.png"
|
||||
size = self.renderer.assets.metadata["images"][name]["size"]
|
||||
expected.paste(self.renderer.assets.image(
|
||||
name, tuple(s(n) for n in size),
|
||||
), (s(100), s(30)))
|
||||
expected.paste(Image.new("RGBA", (s(140), s(150)), "red"),
|
||||
(s(125), s(85)))
|
||||
name = "background/frame/frame.png"
|
||||
size = self.renderer.assets.metadata["images"][name]["size"]
|
||||
expected.paste(self.renderer.assets.image(
|
||||
name, tuple(s(n) for n in size),
|
||||
), (s(75), s(44)))
|
||||
with patch.object(actual, "text"), patch.object(actual, "rectangle"):
|
||||
self.renderer._panel(actual, scene, options, avatar.getvalue())
|
||||
self.assertEqual(actual.image.tobytes(), expected.image.tobytes())
|
||||
|
||||
def test_legacy_panel_fields_progress_and_level_boundaries(self):
|
||||
options = RenderOptions(width=3072)
|
||||
for exp, level, current, threshold in (
|
||||
(0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 600),
|
||||
(599, 1, 399, 600), (600, 2, 0, 1200), (3420, 5, 420, 4200),
|
||||
):
|
||||
data = snapshot(name="农场", point=12580, vip_point=36,
|
||||
experience=exp)
|
||||
scene = self.renderer.scene(self.who, data, 0)
|
||||
canvas = Canvas.new((3072, 1872))
|
||||
with patch.object(canvas, "text") as text, patch.object(
|
||||
canvas, "rectangle",
|
||||
) as rectangle:
|
||||
self.renderer._panel(canvas, scene, options, None)
|
||||
values = [(call.args[0], call.args[1])
|
||||
for call in text.call_args_list]
|
||||
self.assertEqual(values, [
|
||||
((300, 92), "农场"),
|
||||
((365, 164), f"{current} / {threshold}"),
|
||||
((570, 158), str(level)),
|
||||
((330, 211), "12580"), ((570, 211), "36"),
|
||||
])
|
||||
progress = current * 247 // threshold
|
||||
if progress:
|
||||
rectangle.assert_called_once_with(
|
||||
(292, 164, 292 + progress, 189), (171, 194, 41),
|
||||
)
|
||||
else:
|
||||
rectangle.assert_not_called()
|
||||
|
||||
def test_lru_budget_and_shared_image_safety(self):
|
||||
cache = ByteLRU(10)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -58,6 +60,51 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("成熟", await self.call("harvest", message="harvest"))
|
||||
self.assertTrue((self.root / "data/farm-preview.log").exists())
|
||||
|
||||
async def test_shop_filters_and_numbered_commands(self):
|
||||
catalogue = self.runtime.service.catalogue
|
||||
carrot = catalogue.get("胡萝卜")
|
||||
self.runtime.service.catalogue = type(catalogue)([
|
||||
carrot,
|
||||
replace(carrot, plant_id=9001, purchasable=False, name="禁售菜"),
|
||||
replace(carrot, plant_id=9002, enabled=False, name="停用菜"),
|
||||
replace(carrot, plant_id=9003, crop_type=11, name="特殊道具"),
|
||||
])
|
||||
shop = await self.call("shop")
|
||||
self.assertIn("胡萝卜(#1)", shop)
|
||||
for name in ("禁售菜", "停用菜", "特殊道具"):
|
||||
self.assertNotIn(name, shop)
|
||||
await self.call("register")
|
||||
self.assertIn("已完成", await self.call("buy", "#1 1", "number"))
|
||||
self.assertIn("不出售", await self.call("buy", "禁售菜 1", "blocked"))
|
||||
self.assertIn("(#1)", await self.call("inventory"))
|
||||
|
||||
async def test_pest_command_clears_pests_and_preserves_growth(self):
|
||||
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
|
||||
self.assertIn("每只虫", await self.call("help"))
|
||||
self.runtime.service.clock = lambda: 1000
|
||||
await self.call("register")
|
||||
await self.call("buy", "白萝卜 1", "buy")
|
||||
await self.call("sow", "白萝卜 1", "sow")
|
||||
self.assertIn("没有虫", await self.call("clear_pests", message="empty"))
|
||||
duration = self.runtime.service.catalogue.get("白萝卜").growth_seconds
|
||||
self.runtime.service.clock = lambda: 1000 + max(3600, (duration+3)//4)
|
||||
self.assertIn("虫子1只", await self.call("inventory"))
|
||||
first = await self.call("clear_pests", message="clear")
|
||||
self.assertIn("除虫已完成", first)
|
||||
self.assertEqual(first, await self.call("clear_pests", message="clear"))
|
||||
self.assertIn("没有虫", await self.call("clear_pests", message="new"))
|
||||
|
||||
async def test_duplicate_name_reports_numbered_choice(self):
|
||||
catalogue = self.runtime.service.catalogue
|
||||
carrot = catalogue.get("胡萝卜")
|
||||
self.runtime.service.catalogue = type(catalogue)([
|
||||
carrot, replace(carrot, plant_id=9001),
|
||||
])
|
||||
await self.call("register")
|
||||
response = await self.call("buy", "胡萝卜 1", "ambiguous")
|
||||
self.assertIn("同名", response)
|
||||
self.assertIn("#编号", response)
|
||||
|
||||
async def test_missing_message_id_and_other_identity(self):
|
||||
self.assertIn("未执行", await self.call("register", message=None))
|
||||
await self.call("register")
|
||||
@@ -75,7 +122,9 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
("sow", "白萝卜 -2"),
|
||||
("lock", ""), ("farm", "unknown")):
|
||||
self.assertIsInstance(await self.call(action, arguments), str)
|
||||
other = self.module.Runtime(self.root / "without-font")
|
||||
other = self.module.Runtime(
|
||||
self.root / "without-font", self.root / "missing.ttf",
|
||||
)
|
||||
await other.start()
|
||||
try:
|
||||
response = await other.execute("farm", "", **self.context)
|
||||
@@ -83,6 +132,37 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_default_font_uses_host_resources_and_skips_broken_font(self):
|
||||
fonts = self.root / "host-fonts"
|
||||
fonts.mkdir()
|
||||
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
|
||||
shutil.copyfile(choose_font(), fonts / "msyh.ttf")
|
||||
other = self.module.Runtime(self.root / "default-font", font_dir=fonts)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertIsNotNone(other.rendering)
|
||||
self.assertEqual(
|
||||
other.rendering.renderer.font_path,
|
||||
(fonts / "msyh.ttf").resolve(),
|
||||
)
|
||||
await other.execute("register", "", **self.context)
|
||||
result = await other.execute("farm", "", **self.context)
|
||||
self.assertIsInstance(result, bytes)
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_explicit_font_overrides_host_defaults(self):
|
||||
explicit = Path(choose_font()).resolve()
|
||||
other = self.module.Runtime(
|
||||
self.root / "explicit-font", explicit,
|
||||
font_dir=self.root / "not-present",
|
||||
)
|
||||
await other.start()
|
||||
try:
|
||||
self.assertEqual(other.rendering.renderer.font_path, explicit)
|
||||
finally:
|
||||
await other.close()
|
||||
|
||||
async def test_insufficient_funds_has_chinese_message_and_no_debit(self):
|
||||
await self.call("register")
|
||||
before = await self.call("inventory")
|
||||
@@ -119,6 +199,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
||||
uninfo.Uninfo = type("Uninfo", (), {})
|
||||
paths = ModuleType("zhenxun.configs.path_config")
|
||||
paths.DATA_PATH = self.root
|
||||
paths.FONT_PATH = self.root / "fonts"
|
||||
messages = ModuleType("zhenxun.utils.message")
|
||||
sent = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user