fixed # 将资源文件迁移到Service仓库中

This commit is contained in:
2026-10-26 18:53:41 +08:00
parent e5ec38e9d9
commit e7c6eac07f
34 changed files with 1335 additions and 52 deletions
+4 -3
View File
@@ -46,12 +46,13 @@ class BundleTests(unittest.TestCase):
bundle.ensure_assets(self.root / 'gitea', 'Gitea')
self.assertEqual(fetch.call_args.args[0],
'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/assets/assets.zip')
'raw/branch/main/catalogue/releases/'
+ self.expected['catalogue_release'] + '/assets.zip')
bundle.ensure_assets(self.root / 'service', 'farm_service',
'https://farm.example/prefix/')
self.assertEqual(fetch.call_args.args[0],
'https://farm.example/prefix/v1/assets/'
+ self.expected['sha256'] + '/assets.zip')
'https://farm.example/prefix/v1/catalogue/releases/'
+ self.expected['catalogue_release'] + '/assets.zip')
def test_failed_download_leaves_no_partial_and_wrong_hash_is_rejected(self):
def corrupt(url, destination, limit):
+6 -1
View File
@@ -146,7 +146,12 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
Path(choose_font()).read_bytes())
(fonts / 'OFL-Xiaolai.txt').write_bytes((ROOT /
'src/farm_render/data/OFL-Xiaolai.txt').read_bytes())
await plugin.initialize()
with patch(
package.__name__ +
'._vendor.farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline fixture'),
):
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
class Event:
+195
View File
@@ -0,0 +1,195 @@
import copy
import hashlib
import json
from io import BytesIO
from zipfile import ZipFile
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from farm_core.catalogue_release import make_manifest, parse_manifest
from farm_render.catalogue_bundle import load_catalogue_release
ROOT = Path(__file__).resolve().parents[1]
def release(price=21, extra=False):
plants = json.loads((ROOT / 'src/farm_core/data/plants.json').read_bytes())
plants.pop('source_sha256', None)
plants['plants'][0]['price'] = price
metadata = json.loads(
(ROOT / 'src/farm_render/data/metadata.json').read_bytes()
)
if extra:
row = copy.deepcopy(plants['plants'][0])
row.update(id=9001, name='更新测试作物')
plants['plants'].append(row)
metadata['plants'][row['name']] = copy.deepcopy(
metadata['plants'][plants['plants'][0]['name']]
)
files = {
'plants.json': json.dumps(plants, ensure_ascii=False).encode(),
'metadata.json': json.dumps(metadata, ensure_ascii=False).encode(),
**{name: (ROOT / 'src/farm_render/data' / name).read_bytes()
for name in ('assets.zip', 'land-soils.zip')},
}
return make_manifest(files, 'test-v1'), files
class CatalogueReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.manifest, self.files = release(price=99)
def tearDown(self):
self.temp.cleanup()
def fetch(self, url, limit):
if url.endswith('/manifest.json'):
data = json.dumps(self.manifest).encode()
else:
self.assertIn('/releases/' + self.manifest['release'] + '/', url)
data = self.files[url.rsplit('/', 1)[-1]]
self.assertLessEqual(len(data), limit)
return data
def load(self, **kwargs):
return load_catalogue_release(self.root, **kwargs)
def test_new_catalogue_and_metadata_activate_as_one_version(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual(result.origin, 'remote')
self.assertTrue(result.bundle_path.is_file())
self.assertTrue(result.soils_path.is_file())
current = json.loads((self.root / 'catalogue/current.json').read_bytes())
self.assertEqual(current['release'], self.manifest['release'])
def test_404_reuses_verified_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('404')):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual(result.origin, 'cache')
def test_wrong_bytes_do_not_replace_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
before = (self.root / 'catalogue/current.json').read_bytes()
self.manifest, self.files = release(price=100)
self.files['plants.json'] = b'broken'
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(1).price, 99)
self.assertEqual((self.root / 'catalogue/current.json').read_bytes(),
before)
def test_first_offline_start_uses_bootstrap_without_images(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline')):
result = self.load()
self.assertEqual(result.origin, 'bootstrap')
self.assertEqual(len(result.catalogue), 202)
self.assertFalse((self.root / 'catalogue/current.json').exists())
def test_cache_loss_with_unknown_player_id_refuses_start(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline')):
with self.assertRaisesRegex(ValueError, 'catalogue.*ID'):
self.load(required_ids={9001})
def test_incompatible_downgrade_keeps_added_crop(self):
self.manifest, self.files = release(extra=True)
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
self.manifest, self.files = release()
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
result = self.load()
self.assertEqual(result.catalogue.get_by_id(9001).name, '更新测试作物')
self.assertEqual(result.origin, 'cache')
def test_manifest_rejects_bool_unknown_schema_paths_and_fingerprint(self):
for key, value in [('schema', True), ('schema', 2),
('release', '0' * 64)]:
bad = copy.deepcopy(self.manifest)
bad[key] = value
with self.assertRaises(ValueError):
parse_manifest(json.dumps(bad).encode())
bad = copy.deepcopy(self.manifest)
bad['files']['../file'] = bad['files'].pop('assets.zip')
with self.assertRaises(ValueError):
parse_manifest(json.dumps(bad).encode())
def test_selected_service_prefix_is_respected(self):
seen = []
def fetch(url, limit):
seen.append(url)
return self.fetch(url, limit)
with patch('farm_render.catalogue_bundle._fetch', side_effect=fetch):
self.load(source='farm_service',
service_url='https://farm.test/prefix')
self.assertTrue(all(
url.startswith('https://farm.test/prefix/v1/catalogue/')
for url in seen
))
def test_current_matching_remote_still_checks_player_ids(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
with self.assertRaisesRegex(ValueError, 'catalogue.*ID'):
self.load(required_ids={9001})
def test_consistent_but_invalid_metadata_keeps_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
self.files['metadata.json'] = b'{"schema":1,"canvas":[0,0]}'
self.manifest = make_manifest(self.files, 'bad-layout')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertEqual(result.metadata['canvas'], [3072, 1872])
def test_huge_coordinate_keeps_verified_current(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
metadata = json.loads(self.files['metadata.json'])
metadata['positions'][0]['x'] = 10 ** 500
self.files['metadata.json'] = json.dumps(metadata).encode()
self.manifest = make_manifest(self.files, 'huge-coordinate')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertLess(result.metadata['positions'][0]['x'], 10000000)
def test_removing_old_crop_image_is_rejected_even_with_valid_hashes(self):
with patch('farm_render.catalogue_bundle._fetch',
side_effect=self.fetch):
self.load()
metadata = json.loads(self.files['metadata.json'])
name = next(key for key in metadata['images']
if key.startswith('plant/'))
metadata['images'].pop(name)
output = BytesIO()
with ZipFile(BytesIO(self.files['assets.zip'])) as old, \
ZipFile(output, 'w') as new:
for item in old.infolist():
if item.filename != name:
new.writestr(item, old.read(item))
self.files['assets.zip'] = output.getvalue()
self.files['metadata.json'] = json.dumps(metadata).encode()
self.manifest = make_manifest(self.files, 'missing-image')
result = self.load()
self.assertEqual(result.origin, 'cache')
self.assertIn(name, result.metadata['images'])
+34
View File
@@ -0,0 +1,34 @@
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
import unittest
from farm_core import SQLiteStore
class CatalogueStoreTests(unittest.IsolatedAsyncioTestCase):
async def test_missing_store_is_not_created_by_catalogue_check(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'not-created.sqlite3'
store = SQLiteStore(path)
self.assertEqual(await store.catalogue_ids(), set())
self.assertFalse(path.exists())
async def test_existing_ids_read_without_changing_database(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'farm.sqlite3'
store = SQLiteStore(path)
await store.initialize()
await store.close()
with closing(sqlite3.connect(path)) as db, db:
db.execute('INSERT INTO accounts(identity,name) VALUES (?,?)',
('fixture', 'synthetic'))
db.execute('INSERT INTO inventory(identity,plant_id,name,seeds)'
' VALUES (?,?,?,?)', ('fixture', 9001, 'test', 1))
db.execute('INSERT INTO plots(identity,slot,plant) VALUES (?,?,?)',
('fixture', 1, json.dumps({'plant_id': 9002})))
before = path.read_bytes()
self.assertEqual(await store.catalogue_ids(), {9001, 9002})
self.assertEqual(path.read_bytes(), before)
+5 -3
View File
@@ -11,6 +11,8 @@ from export_render_assets import export
class RenderExportTests(unittest.TestCase):
def test_export_preserves_published_land_numbering_and_calibration(self):
_, metadata = export(ROOT)
stored = (ROOT / "src/farm_render/data/metadata.json").read_bytes()
self.assertEqual(json.loads(metadata), json.loads(stored))
self.assertEqual(metadata, stored)
data = json.loads(metadata)
self.assertEqual(len(data['plants']), 202)
self.assertEqual(len(data['positions']), 30)
self.assertEqual(data['placement']['mode'], 'visible_contact_center')
self.assertEqual(metadata, export(ROOT)[1])
+138
View File
@@ -27,6 +27,12 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
module.__path__ = [str(self.root / "built/repository")]
sys.modules[self.package] = module
self.module = importlib.import_module(self.package + ".runtime")
offline = patch(
self.package + '._vendor.farm_render.catalogue_bundle._fetch',
side_effect=OSError('offline fixture'),
)
offline.start()
self.addCleanup(offline.stop)
self.runtime = self.module.Runtime(self.root / "data", choose_font())
self.seed_assets(self.runtime)
await self.runtime.start()
@@ -78,6 +84,133 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1'), bytes)
async def test_catalogue_is_loaded_once_before_store_initialization(self):
import json
from unittest.mock import AsyncMock
await self.call('register')
await self.call('buy', '胡萝卜 1', message='seed')
await self.call('sow', '胡萝卜 1', message='sow')
before = await self.runtime.service.snapshot(self.runtime_identity())
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
original = bundle._bootstrap()
document = json.loads((ROOT / 'src/farm_core/data/plants.json')
.read_bytes())
document['plants'][0]['price'] = 99
document['plants'][0]['harvest'] = 999
updated = replace(original, catalogue=self.module.Catalogue.loads(
json.dumps(document),
))
self.runtime = self.module.Runtime(self.root / 'data', choose_font())
self.seed_assets(self.runtime)
with patch.object(self.module, 'load_catalogue_release',
return_value=updated) as load:
await asyncio.gather(self.runtime.start(), self.runtime.start())
await self.runtime.start()
self.assertEqual(load.call_count, 1)
self.assertEqual(self.runtime.service.catalogue.get_by_id(1).price, 99)
after = await self.runtime.service.snapshot(self.runtime_identity())
self.assertEqual(after.plots[0].plant, before.plots[0].plant)
self.assertNotEqual(after.plots[0].plant.harvest, 999)
async def test_close_during_catalogue_load_does_not_start_service(self):
import threading
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
snapshot = bundle._bootstrap()
entered, release = threading.Event(), threading.Event()
def slow(*args):
entered.set()
if not release.wait(5):
raise TimeoutError('fixture did not release')
return snapshot
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
with patch.object(self.module, 'load_catalogue_release',
side_effect=slow):
starting = asyncio.create_task(self.runtime.start())
await asyncio.wait_for(asyncio.to_thread(entered.wait), 2)
closing = asyncio.create_task(self.runtime.close())
await asyncio.sleep(0)
release.set()
await asyncio.gather(starting, closing)
self.assertFalse(self.runtime.started)
self.assertFalse((self.root / 'other/farm.sqlite3').exists())
async def test_close_during_id_read_prevents_later_start(self):
await self.runtime.close()
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
self.seed_assets(self.runtime)
entered, release = asyncio.Event(), asyncio.Event()
async def slow_ids():
entered.set()
await release.wait()
return set()
with patch.object(self.runtime.store, 'catalogue_ids',
side_effect=slow_ids):
starting = asyncio.create_task(self.runtime.start())
await entered.wait()
await self.runtime.close()
release.set()
await starting
self.assertFalse(self.runtime.started)
self.assertFalse((self.root / 'other/farm.sqlite3').exists())
async def test_cancelled_start_drains_catalogue_download(self):
import threading
await self.runtime.close()
bundle = importlib.import_module(
self.package + '._vendor.farm_render.catalogue_bundle',
)
snapshot = bundle._bootstrap()
entered, release, finished = (threading.Event() for _ in range(3))
def slow(*args):
entered.set()
if not release.wait(5):
raise TimeoutError('fixture did not release')
finished.set()
return snapshot
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
with patch.object(self.module, 'load_catalogue_release',
side_effect=slow):
starting = asyncio.create_task(self.runtime.start())
await asyncio.wait_for(asyncio.to_thread(entered.wait), 2)
starting.cancel()
await asyncio.sleep(0.01)
self.assertFalse(starting.done())
release.set()
with self.assertRaises(asyncio.CancelledError):
await starting
self.assertTrue(finished.is_set())
self.assertIsNone(self.runtime.log_handler)
self.assertFalse(self.runtime.started)
async def test_close_during_initialization_prevents_later_start(self):
await self.runtime.close()
self.runtime = self.module.Runtime(self.root / 'other', choose_font())
self.seed_assets(self.runtime)
entered, release = asyncio.Event(), asyncio.Event()
initialize = self.runtime.store.initialize
async def slow_initialize(**kwargs):
await initialize(**kwargs)
entered.set()
await release.wait()
with patch.object(self.runtime.store, 'initialize',
side_effect=slow_initialize):
starting = asyncio.create_task(self.runtime.start())
await entered.wait()
await self.runtime.close()
release.set()
await starting
self.assertFalse(self.runtime.started)
self.assertIsNone(self.runtime.rendering)
async def test_land_upgrade_command_conditions_charge_and_replay(self):
self.assertEqual(self.module.COMMANDS['土地升级'], 'upgrade')
await self.call('register')
@@ -577,6 +710,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for resource in ('assets', 'font'):
other = self.module.Runtime(self.root / ('stop-' + resource))
self.seed_assets(other)
if resource == 'assets':
next((other.data_dir / 'assets').glob('*/assets.zip')).unlink()
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=TimeoutError()) as fetch:
@@ -598,6 +733,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
await asyncio.sleep(0)
self.assertEqual(fetch.call_count, 1)
await other.close()
self.seed_assets(other)
await other.start()
self.assertIsNotNone(other.rendering)
finally:
@@ -614,6 +750,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
):
other = self.module.Runtime(self.root / ('failed-' + resource))
self.seed_assets(other)
if resource == 'assets':
next((other.data_dir / 'assets').glob('*/assets.zip')).unlink()
try:
with patch.object(self.module, 'ensure_' + resource,
side_effect=failure), \