fixed # 修复作物资源位置不对的历史遗留BUG

add # 新增作物会生虫的逻辑
This commit is contained in:
2026-10-19 17:25:25 +08:00
parent 24ae6102c0
commit 15646525cb
80 changed files with 793505 additions and 226 deletions
+238
View File
@@ -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(), [])
+160
View File
@@ -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)
+156
View File
@@ -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
View File
@@ -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)
+82 -1
View File
@@ -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 = []